From ecdf1ff00374f5bd1044d0b6ff8bfb516d1f0eed Mon Sep 17 00:00:00 2001 From: kitzy Date: Wed, 5 Aug 2026 15:04:49 -0400 Subject: [PATCH] Add TeamViewer Host as a Windows Fleet-maintained app (#50553) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Related issue:** Resolves #50332 Adds **TeamViewer Host** as a Windows Fleet-maintained app. ## The issue's premise was wrong — this needed no new capability #50332 was blocked on "not in winget." It is in winget: `TeamViewer.TeamViewer.Host`, published continuously since **June 2023** (v15.42.8), currently **15.80.4** (merged upstream as microsoft/winget-pkgs#409335, 2026-07-29). I downloaded the live x64 installer and its SHA256 matches that manifest byte-for-byte. It reads as absent because it's nested a level deeper than you'd expect — `manifests/t/TeamViewer/TeamViewer/Host/`, a sibling of the full client's *version* directories rather than of the publisher's package directories. Consequence: this comes off the dependency on #50364, and that FR loses one of its three examples. The other two (#50328, #50329, OLE DB Driver 18/19) still hold — there is no OLE DB package under any winget publisher. ## Identity verified against the installer, not winget metadata Per the `new-fma` golden rule, from the x64 MSI's `Property` and `Registry` tables: | Field | Value | Evidence | |---|---|---| | `unique_identifier` | `TeamViewer Host` | MSI `ProductName`; no `ARPDISPLAYNAME`, empty `Registry` table, so this is the ARP DisplayName | | publisher | `TeamViewer` | MSI `Manufacturer`, equal to the winget locale `Publisher` → no `program_publisher` override | | `installer_scope` | `machine` | `ALLUSERS=1` | | bootstrapper? | No | no `ARPSYSTEMCOMPONENT` | Corroborated independently by [silentinstallhq's PSADT script](https://silentinstallhq.com/teamviewer-host-install-and-uninstall-powershell/), which detects the app with `Get-InstalledApplication -Name 'TeamViewer Host'` and uses `/S` for both install and uninstall. Generated exists query: ```sql SELECT 1 FROM programs WHERE name = 'TeamViewer Host' AND publisher = 'TeamViewer'; ``` No collision with the existing `teamviewer/windows` FMA, which generates an exact `name = 'TeamViewer'`. ## Why exe + `ignore_hash`, matching the full client winget offers Host as an NSIS exe and as a nested `wix` MSI inside a zip. The ingester can't select the zip (`installer.InstallerType` is `zip`, which never normalizes to `msi`), so the exe is the only reachable installer. TeamViewer publishes no version-pinned Host exe — `TeamViewer_Host_Setup_x64_15.80.4.exe` and the x86 equivalent both 404 — so the manifest's URL is the unpinned `TeamViewer_Host_Setup_x64.exe` and `ignore_hash: true` is required. This is vendor asymmetry, not a fixable winget defect: the *full* client does publish pinned exe URLs. Same reason `teamviewer/windows` already sets `ignore_hash`. ## Correcting the coexistence note in #50332 The issue assumed Host and the full client can co-exist. They can't. The Host MSI's `LaunchCondition` table blocks the install outright: > Error 25001: An incompatible TeamViewer package was detected that conflicts with the current MSI package: TeamViewer_Full 64-bit. Please manually uninstall this package. …plus equivalents for Full 32-bit/ARM64, Host ARM64, and the NSIS installs. **Relevant to validation: the validator host must not already have `teamviewer/windows` installed.** ## Icons No `index.ts` change needed — `matchLoosePrefixToKey` treats keys as whole words at the start, so `"teamviewer host"` matches the existing `teamviewer` key and inherits the TeamViewer brand icon. Verified against the real 1,163-key map. The website resolves its icon from the slug (`app-icon-${slug}-60x60@2x.png`) with no such fallback, so `app-icon-teamviewer-host-60x60@2x.png` is added — a copy of the existing TeamViewer brand asset, since Host ships the same logo. ## Two things for review 1. **The PowerShell is unverified.** Authored on macOS with no PowerShell available, so install/uninstall have not been executed. The uninstall script searches ARP by DisplayName instead of a hardcoded key, and uses the three-shape `UninstallString` parser (TeamViewer's is unquoted and contains a space in `C:\Program Files\...`, which the older `.Split('"')` approach in `teamviewer_uninstall.ps1` mishandles). Validator run is the real check. 2. **Category mismatch with the macOS side.** This uses `Communication`, matching the merged `teamviewer/windows`. #47121 adds `teamviewer-host/darwin` with `Productivity`. Worth reconciling — and that PR will want this same website PNG, so expect a trivial conflict. `name` is `TeamViewer Host`, matching #47121 so both platforms group together in the FMA library. ## Testing - [x] `go test ./cmd/maintained-apps/... ./ee/maintained-apps/...` passes - [x] Generator is idempotent — re-running produces no diff and preserves the `apps.json` description - [x] `apps.json` is valid JSON with no empty descriptions - [x] Live installer SHA256 confirmed against the winget manifest - [ ] FMA validator: install → detect → uninstall on a Windows host **(pending — needs a host without the full TeamViewer client)** No shared code changed. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5 Co-authored-by: Allen Houchins --- .../scripts/teamviewer-host_install.ps1 | 29 ++++++ .../scripts/teamviewer-host_uninstall.ps1 | 95 ++++++++++++++++++ .../inputs/winget/teamviewer-host.json | 15 +++ ee/maintained-apps/outputs/apps.json | 7 ++ .../outputs/teamviewer-host/windows.json | 22 ++++ .../app-icon-teamviewer-host-60x60@2x.png | Bin 0 -> 2338 bytes 6 files changed, 168 insertions(+) create mode 100644 ee/maintained-apps/inputs/winget/scripts/teamviewer-host_install.ps1 create mode 100644 ee/maintained-apps/inputs/winget/scripts/teamviewer-host_uninstall.ps1 create mode 100644 ee/maintained-apps/inputs/winget/teamviewer-host.json create mode 100644 ee/maintained-apps/outputs/teamviewer-host/windows.json create mode 100644 website/assets/images/app-icon-teamviewer-host-60x60@2x.png diff --git a/ee/maintained-apps/inputs/winget/scripts/teamviewer-host_install.ps1 b/ee/maintained-apps/inputs/winget/scripts/teamviewer-host_install.ps1 new file mode 100644 index 0000000000..877009684d --- /dev/null +++ b/ee/maintained-apps/inputs/winget/scripts/teamviewer-host_install.ps1 @@ -0,0 +1,29 @@ +# Learn more about .exe install scripts: +# http://fleetdm.com/learn-more-about/exe-install-scripts + +$exeFilePath = "${env:INSTALLER_PATH}" + +try { + +# TeamViewer Host ships as an NSIS installer, which takes "/S" for a silent +# install. The MSI underneath sets ALLUSERS=1, so this always installs +# machine-wide. +$processOptions = @{ + FilePath = "$exeFilePath" + ArgumentList = "/S" + PassThru = $true + Wait = $true +} + +# Start process and track exit code +$process = Start-Process @processOptions +$exitCode = $process.ExitCode + +# Prints the exit code +Write-Host "Install exit code: $exitCode" +Exit $exitCode + +} catch { + Write-Host "Error: $_" + Exit 1 +} diff --git a/ee/maintained-apps/inputs/winget/scripts/teamviewer-host_uninstall.ps1 b/ee/maintained-apps/inputs/winget/scripts/teamviewer-host_uninstall.ps1 new file mode 100644 index 0000000000..9ff2a4a7ca --- /dev/null +++ b/ee/maintained-apps/inputs/winget/scripts/teamviewer-host_uninstall.ps1 @@ -0,0 +1,95 @@ +# TeamViewer Host registers itself in Add/Remove Programs with the DisplayName +# "TeamViewer Host" (the MSI ProductName; there is no ARPDISPLAYNAME override) +# and the Publisher "TeamViewer" (the MSI Manufacturer). +# +# The trailing wildcard tolerates a version suffix if TeamViewer ever adds one, +# and the "Host" in the pattern keeps this from matching the full TeamViewer +# client, which registers as plain "TeamViewer" and ships as its own +# Fleet-maintained app (teamviewer/windows). +$softwareNameLike = "TeamViewer Host*" + +# Matched with a trailing wildcard so this filter stays strictly looser than the +# app's exists query (publisher = 'TeamViewer'). Uninstall must never be more +# selective than detection, or the app reports installed with no way to remove it. +$publisherLike = "TeamViewer*" + +$paths = @( + 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall', + 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall' +) + +try { + +$uninstall = $null +foreach ($p in $paths) { + $items = Get-ItemProperty "$p\*" -ErrorAction SilentlyContinue | Where-Object { + $_.DisplayName -like $softwareNameLike -and $_.Publisher -like $publisherLike + } + if ($items) { $uninstall = $items | Select-Object -First 1; break } +} + +if (-not $uninstall) { + Write-Host "Uninstall entry not found for $softwareNameLike" + Exit 0 +} + +if (-not $uninstall.UninstallString -and -not $uninstall.QuietUninstallString) { + Write-Host "Error: uninstall entry for $($uninstall.DisplayName) has no UninstallString or QuietUninstallString" + Exit 1 +} + +# Get the uninstall command. Some uninstallers do not include +# 'QuietUninstallString' and require a flag to run silently. +$uninstallCommand = if ($uninstall.QuietUninstallString) { + $uninstall.QuietUninstallString +} else { + $uninstall.UninstallString +} + +# UninstallString comes in three shapes. TeamViewer's is unquoted and +# contains a space ("C:\Program Files\TeamViewer\uninstall.exe"), so +# capture through the .exe rather than splitting on the first space. +$existingArgs = '' +if ($uninstallCommand -match '^\s*"([^"]+)"\s*(.*)$') { + # Quoted path, optionally followed by args + $uninstallCommand = $Matches[1] + $existingArgs = $Matches[2].Trim() +} elseif ($uninstallCommand -match '(?i)^\s*(.+?\.exe)\s*(.*)$') { + # Unquoted path that may contain spaces + $uninstallCommand = $Matches[1] + $existingArgs = $Matches[2].Trim() +} elseif ($uninstallCommand -match '^\s*(\S+)\s*(.*)$') { + # Bare token (e.g. MsiExec.exe /X{GUID}) + $uninstallCommand = $Matches[1] + $existingArgs = $Matches[2].Trim() +} else { + Throw "Could not parse uninstall string: $uninstallCommand" +} + +# NSIS installers require /S for a silent uninstall. Append it unless the +# registered command already runs silently. +if ($existingArgs -notmatch '(?i)(^|\s)/S(\s|$)') { + $uninstallArgs = "$existingArgs /S".Trim() +} else { + $uninstallArgs = $existingArgs +} + +Write-Host "Uninstall command: $uninstallCommand" +Write-Host "Uninstall args: $uninstallArgs" + +$processOptions = @{ + FilePath = $uninstallCommand + ArgumentList = $uninstallArgs + PassThru = $true + Wait = $true +} + +$process = Start-Process @processOptions +$exitCode = $process.ExitCode +Write-Host "Uninstall exit code: $exitCode" +Exit $exitCode + +} catch { + Write-Host "Error: $_" + Exit 1 +} diff --git a/ee/maintained-apps/inputs/winget/teamviewer-host.json b/ee/maintained-apps/inputs/winget/teamviewer-host.json new file mode 100644 index 0000000000..04fc788fdc --- /dev/null +++ b/ee/maintained-apps/inputs/winget/teamviewer-host.json @@ -0,0 +1,15 @@ +{ + "name": "TeamViewer Host", + "slug": "teamviewer-host/windows", + "package_identifier": "TeamViewer.TeamViewer.Host", + "unique_identifier": "TeamViewer Host", + "install_script_path": "ee/maintained-apps/inputs/winget/scripts/teamviewer-host_install.ps1", + "uninstall_script_path": "ee/maintained-apps/inputs/winget/scripts/teamviewer-host_uninstall.ps1", + "installer_arch": "x64", + "installer_type": "exe", + "installer_scope": "machine", + "ignore_hash": true, + "default_categories": [ + "Communication" + ] +} diff --git a/ee/maintained-apps/outputs/apps.json b/ee/maintained-apps/outputs/apps.json index 610fa62cd9..74a8bac058 100644 --- a/ee/maintained-apps/outputs/apps.json +++ b/ee/maintained-apps/outputs/apps.json @@ -8436,6 +8436,13 @@ "unique_identifier": "com.apptorium.TeaCode-dm", "description": "TeaCode is a text expanding app for developers." }, + { + "name": "TeamViewer Host", + "slug": "teamviewer-host/windows", + "platform": "windows", + "unique_identifier": "TeamViewer Host", + "description": "TeamViewer Host is a remote access agent for unattended 24/7 access to a device, for uses such as remote device monitoring and server maintenance." + }, { "name": "TeamViewer", "slug": "teamviewer/darwin", diff --git a/ee/maintained-apps/outputs/teamviewer-host/windows.json b/ee/maintained-apps/outputs/teamviewer-host/windows.json new file mode 100644 index 0000000000..c0617fe8c3 --- /dev/null +++ b/ee/maintained-apps/outputs/teamviewer-host/windows.json @@ -0,0 +1,22 @@ +{ + "versions": [ + { + "version": "15.80.4", + "queries": { + "exists": "SELECT 1 FROM programs WHERE name = 'TeamViewer Host' AND publisher = 'TeamViewer';", + "patched": "SELECT 1 WHERE NOT EXISTS (SELECT 1 FROM programs WHERE name = 'TeamViewer Host' AND publisher = 'TeamViewer' AND version_compare(version, '15.80.4') < 0);" + }, + "installer_url": "https://download.teamviewer.com/download/version_15x/TeamViewer_Host_Setup_x64.exe", + "install_script_ref": "0c887797", + "uninstall_script_ref": "6e2af20a", + "sha256": "no_check", + "default_categories": [ + "Communication" + ] + } + ], + "refs": { + "0c887797": "# Learn more about .exe install scripts:\n# http://fleetdm.com/learn-more-about/exe-install-scripts\n\n$exeFilePath = \"${env:INSTALLER_PATH}\"\n\ntry {\n\n# TeamViewer Host ships as an NSIS installer, which takes \"/S\" for a silent\n# install. The MSI underneath sets ALLUSERS=1, so this always installs\n# machine-wide.\n$processOptions = @{\n FilePath = \"$exeFilePath\"\n ArgumentList = \"/S\"\n PassThru = $true\n Wait = $true\n}\n\n# Start process and track exit code\n$process = Start-Process @processOptions\n$exitCode = $process.ExitCode\n\n# Prints the exit code\nWrite-Host \"Install exit code: $exitCode\"\nExit $exitCode\n\n} catch {\n Write-Host \"Error: $_\"\n Exit 1\n}\n", + "6e2af20a": "# TeamViewer Host registers itself in Add/Remove Programs with the DisplayName\n# \"TeamViewer Host\" (the MSI ProductName; there is no ARPDISPLAYNAME override)\n# and the Publisher \"TeamViewer\" (the MSI Manufacturer).\n#\n# The trailing wildcard tolerates a version suffix if TeamViewer ever adds one,\n# and the \"Host\" in the pattern keeps this from matching the full TeamViewer\n# client, which registers as plain \"TeamViewer\" and ships as its own\n# Fleet-maintained app (teamviewer/windows).\n$softwareNameLike = \"TeamViewer Host*\"\n\n# Matched with a trailing wildcard so this filter stays strictly looser than the\n# app's exists query (publisher = 'TeamViewer'). Uninstall must never be more\n# selective than detection, or the app reports installed with no way to remove it.\n$publisherLike = \"TeamViewer*\"\n\n$paths = @(\n 'HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall',\n 'HKLM:\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall'\n)\n\ntry {\n\n$uninstall = $null\nforeach ($p in $paths) {\n $items = Get-ItemProperty \"$p\\*\" -ErrorAction SilentlyContinue | Where-Object {\n $_.DisplayName -like $softwareNameLike -and $_.Publisher -like $publisherLike\n }\n if ($items) { $uninstall = $items | Select-Object -First 1; break }\n}\n\nif (-not $uninstall) {\n Write-Host \"Uninstall entry not found for $softwareNameLike\"\n Exit 0\n}\n\nif (-not $uninstall.UninstallString -and -not $uninstall.QuietUninstallString) {\n Write-Host \"Error: uninstall entry for $($uninstall.DisplayName) has no UninstallString or QuietUninstallString\"\n Exit 1\n}\n\n# Get the uninstall command. Some uninstallers do not include\n# 'QuietUninstallString' and require a flag to run silently.\n$uninstallCommand = if ($uninstall.QuietUninstallString) {\n $uninstall.QuietUninstallString\n} else {\n $uninstall.UninstallString\n}\n\n# UninstallString comes in three shapes. TeamViewer's is unquoted and\n# contains a space (\"C:\\Program Files\\TeamViewer\\uninstall.exe\"), so\n# capture through the .exe rather than splitting on the first space.\n$existingArgs = ''\nif ($uninstallCommand -match '^\\s*\"([^\"]+)\"\\s*(.*)$') {\n # Quoted path, optionally followed by args\n $uninstallCommand = $Matches[1]\n $existingArgs = $Matches[2].Trim()\n} elseif ($uninstallCommand -match '(?i)^\\s*(.+?\\.exe)\\s*(.*)$') {\n # Unquoted path that may contain spaces\n $uninstallCommand = $Matches[1]\n $existingArgs = $Matches[2].Trim()\n} elseif ($uninstallCommand -match '^\\s*(\\S+)\\s*(.*)$') {\n # Bare token (e.g. MsiExec.exe /X{GUID})\n $uninstallCommand = $Matches[1]\n $existingArgs = $Matches[2].Trim()\n} else {\n Throw \"Could not parse uninstall string: $uninstallCommand\"\n}\n\n# NSIS installers require /S for a silent uninstall. Append it unless the\n# registered command already runs silently.\nif ($existingArgs -notmatch '(?i)(^|\\s)/S(\\s|$)') {\n $uninstallArgs = \"$existingArgs /S\".Trim()\n} else {\n $uninstallArgs = $existingArgs\n}\n\nWrite-Host \"Uninstall command: $uninstallCommand\"\nWrite-Host \"Uninstall args: $uninstallArgs\"\n\n$processOptions = @{\n FilePath = $uninstallCommand\n ArgumentList = $uninstallArgs\n PassThru = $true\n Wait = $true\n}\n\n$process = Start-Process @processOptions\n$exitCode = $process.ExitCode\nWrite-Host \"Uninstall exit code: $exitCode\"\nExit $exitCode\n\n} catch {\n Write-Host \"Error: $_\"\n Exit 1\n}\n" + } +} diff --git a/website/assets/images/app-icon-teamviewer-host-60x60@2x.png b/website/assets/images/app-icon-teamviewer-host-60x60@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..298113cfb141405a333fc521681fba39f2ad301a GIT binary patch literal 2338 zcmV+-3ElRIP)*D0<<>&3<f+?<;^gY#u*9&{w41?hddfEzi*b98z3VGQKgyIW+-42cE z>ht?Ync{=O@!RbAg1710>iD71^u5~ik;3aoq3EB;?Sj1R7<$&e-SuRq86=_;7%8G+koweNGb>?)Jx8i(ML$niIw>8;D>zTWqs%<`bh z??|HQ8;#{ho8*zX;#HpFMU>tpfY(>8?MbKYNT%#Jkl?S^_I11P$Jyytrs&7k=SG&_ zGKbi&)bp;;@EnurbFb$rkmEz5=$^piX{6q0quoG`+1%~=4tm*dm-u@C000hjQchC< z-w==>pJ4Cr&oCg*piqEKqW}O0lu1NERA}C`nB8uoKoo%eS-b6Kb0(39HR%m0fh^HB zL?NL>skV*ojcxjKv1!_yegAjZ12fKmaFDg|3Cx+{%zVs94A@e1PI_nGSz~-Z>z#C3 zdA@Z~+Bm&$d)iJHTknFEe~I+vK^3x!5wD3d2}PRfpjt48XZfGO9ZI^L%yip~jl;g+ zG80_x0wK*EgnpNl>ZB0WaLiacHR&I1_PXj9z%Q8KQWc_<5#1$xW$hoS`Hubm5zq!2 z|4Poe(`Hxo>uAQT&A;a^dri+*6NNZxqP`2XB$AC{S4V-$Ndms}A^ca?O%|Fl58L>L zdl8~t^p=@eOZ7~3PudkSi?d=ZUhEzl^OHG^s!C;sbW`3`QXj`t?%;%}nUb>)w{y#K z5X+iBu8LHp3+J)ZPV0o-Y5&9Hi}M%um_EO7dsXh(39fWdIsg6T*#rXfc^+z230SZ7 zlYMI1P#b2^_@1u%^LseJ{amGI#+0#8t=L)Xl=&hJ8)-tzgbLVBILwo~4xgR27%R@p z@bnCoK{X{-Fs8(`J#>JAR?{ePIX<<>G^SNatm_!*2}2&nHBsnKHXMQN#Y5o;95KGM z2r;703xwTY!jV{69gn1hi)p+m9F6Vi1c$|26TX1`bgbBdFJhI88}p+17_kF2V3mmn zS#-Xmw-z&wrSoBNljOwmoCP&urH7@(^?H@Ls`v$JWLsVAnNTC9S5>^Rp;qioMX>`l zv#cf#pmx?(#E}iPV{eMY#sEJG2O7XK3?;#cixqe-f<}m>VUQ84>v5qGTqBJXE7s5u zvjbum8p6$sFLoHl#S9u_h5{jyV#NbA#xm{M7e~+>=B7j(K!YrBjVZAW4dN0r9$k!9 z6<$uJP{`Cde9=*C!EH3+VhrLKw*{1ATkr4G74^gUA@yP;;bMskW!!J$e?`mcd`ONie}v`@nrU7wN}x_F0ofMiC9so9uIPuKZ7rw1gy zF7Qb8f)KmJjl9w0r}2b2kZg5K!wo+kaf|;U#AdOUejKA8th}H|{VOXzADzb(-CJ%j z$YKMDy_Z^~z8xj9ofwJhlbm*KAcKK?b{MRngh-@G0vGo9I2Z7x7L~U>?IihaH4rS5 zqY=$!vk7NC!+jFhuno)gdXwZ}7m<$9;#^karE0EoX?Ci)m)LkngjnIcpf$ZXov!O^h7l%hwg8CgrHb}Vnn}A@onxkMhRNU z-A3^psW8sHD~1OLRsdOt62Vz`)h2puTHXh(VpJ1fBO`)H^%W2VaPrC(6y?np%7D#6 zSUm`C5>n$K6i-J@94^Qj$xX0EPPUK-HMCZEBQg=DNI0km{fK8H=(Wb?QN>vv>ZO$Yrx{Ay$o_6h8kWKD9#x)#V45i=?A$B!&DW$GEBo-1Fq@Z0U#L9z*E)+{jvV~+u8P>bfr;HhB z?O?)6tVqZ<;*56aj98e#V77gM1U07kmLOJjC+%5&>F=cYMA;cx#Si2&;wUB?87i%v zd`(>Wyd9DjNh%-xLJZ(%`YmaZ5sI`}Q<4_RS$2jPNl0squgr{tK>jSdA+3>{;^1!p zQtl60OL;6Z6PAFBp`vLm<(v!Qf+)pR+Ddt>4TiYYlyjR{(hJoic9xMpw}p8!Vdrqh{_~GVWG?gXLsR zil?|PMxWKVm{oWWD^MLZ=Z(5%VklMv75a^NjkSFxHod^OL9dAxn+#g?qS!*znis{c z>vr7=T^YV?e$h+nvdbch5$LMxr@b$|zstH+qcTR!X+?avcUTxcU6o=x2ftQ**McFo zuskT~cWinUl&%U~ZM#T38^BI39%_fijv ziVbTLu7-<=&z)3h;Ishhc(IHx+_KY z+_UEToEcUtZ_XL}$8Ne0vzOL(}M2H+;P6M z-3k+WWF~N@PfjizXVvM@4nP46aA2Pf;(}4$QqA?AYM)va;gz}At^g@NreY1Xu!dq` zb|LsJ+P}hBEq!!O<(8C*8nO$v4w=|TU&WRb;NqL}ns$yvI>Ajixb5h1-Z=`ZN8EO# za&{(1P|qh0z>% literal 0 HcmV?d00001