diff --git a/CHANGELOG.en-US.md b/CHANGELOG.en-US.md index 7181799..24b1785 100644 --- a/CHANGELOG.en-US.md +++ b/CHANGELOG.en-US.md @@ -2,6 +2,10 @@ English (US) release notes, mirroring CHANGELOG.md (Brazilian Portuguese). Before publishing a version, add a `## [VERSION]` section to **both** files: the workflow publishes the section matching the tag from each one and fails if either is missing. +## [Unreleased] +### What's new +- Settings → TV: console mode turns the TV on and switches to the PC's HDMI input, and can put it in standby on restore. First route: Google TV / Android TV over the network (ADB), nothing to install on the PC. (#93) + ## [1.6.0-alpha.3] ### What's new - Session menu: a pinned panel at the top of the side column with the **File explorer (ControlFS)**, the team's open-source, controller-first file manager. One press opens ControlFS on top, always full screen on the session screen (or brings it to the front if already open); if it isn't installed, its download page opens. Console Mode only calls it through its own `--start` flag, never closes it, and never sends F11 to another window if Windows denies focus. (#122) @@ -11,6 +15,7 @@ English (US) release notes, mirroring CHANGELOG.md (Brazilian Portuguese). Befor - Sounds in the Console interface when you move the focus, pick and go back (made by the app itself, at a low volume). Turn them off in Settings → Interface sounds. (#117) - Controller shortcuts of your choice: the button that opens Console Mode, the session menu's and the one that goes back to the PC are now set in Settings (hold the buttons you want and let go on the same controller). Capture never combines buttons from different controllers. None is on by default: the first time you open this version the app shows the setup, with a suggestion for each action (Home, Select + Y and Start + Select). One button can't serve two actions. If you used the Home button or Start + Select before, choose them again. (#116) - Settings → "Receive test versions (alpha and beta)": anyone can join the tests of upcoming versions from the app. Off by default; turning it on shows a warning that test versions can have bugs. +- Settings → TV: console mode turns the TV on and switches it to the PC's HDMI input, and can put it in standby on restore. First route: Google TV / Android TV over the network (ADB), nothing to install on the PC. (#93) ### Fixes - Session menu: if Windows cannot activate the selected window, the menu stays open so you can try again. (#120) diff --git a/CHANGELOG.md b/CHANGELOG.md index 068b66d..a75d083 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ Notas em português do Brasil; a versão em inglês (Estados Unidos) fica em `CHANGELOG.en-US.md`. Antes de publicar uma versão, adicione uma seção `## [VERSÃO]` com o changelog daquela versão **nos dois arquivos**. O workflow publica as duas seções correspondentes à tag na mesma release e falha se faltar alguma. +## [Unreleased] +### Novidades +- Ajustes → TV: o modo console liga a TV e troca para a entrada HDMI do PC, e pode colocá-la em espera ao restaurar. Primeiro caminho: Google TV / Android TV pela rede (ADB), sem instalar nada no PC. (#93) + ## [1.6.0-alpha.3] ### Novidades - Menu da sessão: painel fixo no topo da lateral com o **Explorador de arquivos (ControlFS)**, o gerenciador de arquivos de código aberto da equipe, feito para o controle. Um toque abre o ControlFS por cima, sempre em tela cheia na tela da sessão (ou o traz para a frente se já estiver aberto); se não estiver instalado, abre a página de download. O Console Mode só o chama pelo `--start` dele, não o encerra nem envia F11 a outra janela se o Windows negar o foco. (#122) @@ -11,6 +15,7 @@ Notas em português do Brasil; a versão em inglês (Estados Unidos) fica em `CH - Sons na interface Console ao mover o foco, escolher e voltar (sons criados pelo próprio app, em volume baixo). Dá para desligar em Ajustes → Sons da interface. (#117) - Atalhos do controle à sua escolha: o botão que abre o Console Mode, o do menu da sessão e o de voltar ao PC agora são configuráveis em Ajustes (basta segurar os botões que você quer e soltar no mesmo controle). A captura não mistura botões de controles diferentes. Nenhum vem ligado: na primeira abertura desta versão o app mostra a configuração, com uma sugestão para cada ação (Home, Select + Y e Start + Select). Um mesmo botão não pode servir para duas ações. Quem usava o botão Home ou Start + Select precisa escolher de novo. (#116) - Ajustes → "Receber versões de teste (alpha e beta)": qualquer pessoa pode entrar nos testes das próximas versões pelo próprio app. Desligado por padrão; ao ligar, o app avisa que a versão pode ter bugs. +- Ajustes → TV: o modo console liga a TV e troca para a entrada HDMI do PC, e pode colocá-la em espera ao restaurar. Primeiro caminho: Google TV / Android TV pela rede (ADB), sem instalar nada no PC. (#93) ### Correções - Menu da sessão: se o Windows não conseguir ativar a janela escolhida, o menu continua aberto para tentar de novo. (#120) diff --git a/README.md b/README.md index 1ab7131..cc34cd7 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,7 @@ You pick the controller shortcuts yourself: none is on at first, the app asks th - Hide the other screens by **disconnecting** them, **black overlays** or **DDC/CI** - Per-screen **resolution and refresh rate**, **HDR**, **VRR** and an **FPS limit** (RTSS) while you play - Audio goes to the TV (or any output) and comes back afterwards +- [Turns the TV on and switches it to the PC's input](docs/GUIDE.md#tv-control) (Google TV / Android TV over the network), and can put it in standby afterwards - Launches **Steam Big Picture**, **Playnite fullscreen** or **Xbox** mode - Two interfaces: **Desktop** (mouse) and **Console** (full screen, Xbox and PlayStation pads) - Automation with `consolemode://start` / `stop` / `menu` links (Stream Deck, scripts) and a [local control API](docs/GUIDE.md#local-control-api) diff --git a/README.pt-BR.md b/README.pt-BR.md index 49c3f9e..54a868a 100644 --- a/README.pt-BR.md +++ b/README.pt-BR.md @@ -44,6 +44,7 @@ Os atalhos do controle são você quem escolhe: nenhum vem ligado, o app pergunt - Esconde as outras telas **desconectando**, com **cortinas pretas** ou por **DDC/CI** - **Resolução e Hz** por tela, **HDR**, **VRR** e **limite de FPS** (RTSS) enquanto você joga - O áudio vai para a TV (ou qualquer saída) e volta depois +- [Liga a TV e troca para a entrada do PC](docs/GUIDE.pt-BR.md#controle-da-tv) (Google TV / Android TV pela rede), e pode colocá-la em espera no fim - Abre **Steam Big Picture**, **Playnite em tela cheia** ou o **Modo Xbox** - Duas interfaces: **Desktop** (mouse) e **Console** (tela cheia, controles Xbox e PlayStation) - Automação com os links `consolemode://start` / `stop` / `menu` (Stream Deck, scripts) e uma [API de controle local](docs/GUIDE.pt-BR.md#api-de-controle-local) diff --git a/docs/GUIDE.md b/docs/GUIDE.md index a9bfddd..52fd7a6 100644 --- a/docs/GUIDE.md +++ b/docs/GUIDE.md @@ -23,6 +23,29 @@ You can also restore anytime from the tray (*Restore setup* / *Show window*). Wi `start`, `stop` and `show` do exactly what the matching `consolemode://` link does, then reply once the app has settled (`ok:false` with an `error` if console mode didn't start or the restore didn't finish). `status` only reads. Only the signed-in user and LocalSystem can connect; nothing is exposed to the network. +## TV control + +Settings → **TV** can turn the TV on and switch it to the PC's HDMI input when console mode starts, and optionally put it back in standby after the desk is restored. A TV that doesn't answer never blocks console mode: the app logs it and waits for the game screen as usual. + +With the default, **Don't control it**, nothing is sent and nothing is added to starting or restoring. With a route selected, the TV step runs first when console mode starts and is capped at **30 seconds** (the worst case, when the TV doesn't answer and Wake-on-LAN is tried); the optional standby on restore is capped at **15 seconds**. A TV that is already on usually answers in about a second or two. + +Most PC graphics cards can't send HDMI-CEC, so the app talks to the TV over the network instead: + +### Google TV / Android TV + +TCL, Sony, Hisense, Philips and other TVs running Google TV or Android TV, through ADB (the Android debugging protocol). Nothing to install on the PC. + +1. On the TV: **Settings → System → About**, press **Android TV OS build** 7 times to unlock Developer options. +2. **Settings → System → Developer options**: turn on **USB debugging** (on some TVs, **Network debugging** / **ADB over network**). +3. In Console Mode, pick *Google TV / Android TV*, enter the TV's IP (Settings → Network on the TV; reserve it in your router) and the HDMI input the PC uses. +4. Press **Test now**. The TV asks "Allow debugging from this computer?": tick **Always allow** and press **Allow**. + +The PC's ADB key is stored encrypted for your Windows user (DPAPI), so the TV only asks once. If the data folder is copied to another Windows user or PC, the key can't be read there: the app creates a new one and the TV asks "Allow debugging?" again. + +Waking uses the Android wake-up key, then the **HDMI 1-4** key. If your TV ignores that key, set **Input command** to any Android shell command that opens the PC's input. If the TV drops off the network in standby, fill in its **MAC address** so the app sends Wake-on-LAN first (the TV's "Wake on network" / "Wake on Wi-Fi" option must be on). + +"Wireless debugging" with a pairing code (Android 11+ phones) is a different, TLS-wrapped protocol and isn't supported: use USB / network debugging. + ## Optional extras ### HDR diff --git a/docs/GUIDE.pt-BR.md b/docs/GUIDE.pt-BR.md index f5c5863..1fdeae2 100644 --- a/docs/GUIDE.pt-BR.md +++ b/docs/GUIDE.pt-BR.md @@ -23,6 +23,29 @@ Os links `consolemode://` não dão resposta. Ferramentas que precisam de uma `start`, `stop` e `show` fazem o mesmo que o link `consolemode://` correspondente e respondem quando o app terminou (`ok:false` com `error` se o modo console não entrou ou a restauração não terminou). `status` só consulta. Só o usuário logado e o LocalSystem conseguem conectar; nada fica exposto na rede. +## Controle da TV + +Ajustes → **TV** pode ligar a TV e trocar para a entrada HDMI do PC quando o modo console começa e, se você quiser, colocá-la em espera depois que a mesa volta. Uma TV que não responde nunca trava o modo console: o app registra no log e espera a tela de jogo como sempre. + +Com o padrão, **Não controlar**, nada é enviado e nada é acrescentado ao iniciar nem ao restaurar. Com um caminho escolhido, a etapa da TV roda primeiro quando o modo console começa e tem limite de **30 segundos** (o pior caso, quando a TV não responde e o Wake-on-LAN é tentado); a espera opcional ao restaurar tem limite de **15 segundos**. Uma TV que já está ligada costuma responder em um ou dois segundos. + +A maioria das placas de vídeo de PC não envia HDMI-CEC, então o app fala com a TV pela rede: + +### Google TV / Android TV + +TVs TCL, Sony, Hisense, Philips e outras com Google TV ou Android TV, via ADB (o protocolo de depuração do Android). Nada para instalar no PC. + +1. Na TV: **Configurações → Sistema → Sobre**, aperte **Build do Android TV OS** 7 vezes para liberar as Opções do desenvolvedor. +2. **Configurações → Sistema → Opções do desenvolvedor**: ative **Depuração USB** (em algumas TVs, **Depuração pela rede** / **ADB pela rede**). +3. No Console Mode, escolha *Google TV / Android TV*, informe o IP da TV (Configurações → Rede na TV; reserve esse IP no roteador) e a entrada HDMI do PC. +4. Aperte **Testar agora**. A TV pergunta "Permitir depuração deste computador?": marque **Sempre permitir** e aperte **Permitir**. + +A chave ADB deste PC fica guardada criptografada para o seu usuário do Windows (DPAPI), então a TV só pergunta uma vez. Se a pasta de dados for copiada para outro usuário do Windows ou outro PC, a chave não pode ser lida lá: o app cria outra e a TV pergunta "Permitir depuração?" de novo. + +Para acordar, o app usa a tecla de despertar do Android e depois a tecla **HDMI 1-4**. Se a sua TV ignorar essa tecla, preencha **Comando da entrada** com qualquer comando de shell do Android que abra a entrada do PC. Se a TV sai da rede em espera, informe o **endereço MAC** para o app mandar Wake-on-LAN antes (a opção "Ligar pela rede" / "Wake on Wi-Fi" da TV precisa estar ativa). + +A "Depuração sem fio" com código de pareamento (Android 11+ em celulares) é outro protocolo, com TLS, e não é suportada: use a depuração USB / pela rede. + ## Extras opcionais ### HDR diff --git a/docs/TESTING.md b/docs/TESTING.md index 01c1347..53fd908 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -238,6 +238,23 @@ Pré-condições: lançador = Steam Big Picture; Ajustes → "Fechar a Steam ao - [ ] Lançador Playnite ou Modo Xbox: a Steam não é tocada. Resultado: ______ - [ ] Se o Big Picture demorar a fechar (travado), a restauração da mesa continua e termina normalmente (o log mostra `Big Picture ainda aberto`). Resultado: ______ +## 3c. Controle da TV: Google TV / Android TV (issue #75) + +Pré-condições: TV Google TV / Android TV na mesma rede do PC, com **Depuração USB** (ou "Depuração pela rede") ativada nas Opções do desenvolvedor; Ajustes → TV → *Google TV / Android TV* com o IP e a entrada HDMI do PC. + +- [ ] **Testar agora** na primeira vez: a TV pede "Permitir depuração"; com "Sempre permitir" + "Permitir", a TV acorda e vai para a entrada do PC. O status mostra sucesso. Resultado: ______ +- [ ] **Testar agora** de novo: não pede mais permissão; com a TV em espera, ela liga e troca a entrada. Resultado: ______ +- [ ] Recusar o pedido na TV (ou esperar 60 s): o status explica que a TV não autorizou este PC. Resultado: ______ +- [ ] IP errado / TV fora da rede: o status diz que a TV não respondeu, em poucos segundos. Resultado: ______ +- [ ] **Jogar agora** com a TV em espera: ela liga, troca para o PC e o modo console segue normal. O log tem `TV: ligar (androidTv) ok`. Resultado: ______ +- [ ] Com a TV desligada da tomada: o modo console não trava; o log tem `TV: ligar ... falhou` e o fluxo segue (a TV não aparece, o app avisa como antes). Resultado: ______ +- [ ] TV que some da rede em espera + **MAC** preenchido: o log mostra `enviando Wake-on-LAN` e a TV liga (com "Ligar pela rede" ativo na TV). Resultado: ______ +- [ ] **Colocar a TV em espera ao restaurar** ligado: ao sair do Big Picture, a mesa volta e depois a TV entra em espera. Desligado (padrão): a TV continua ligada. Resultado: ______ +- [ ] TV que ignora a tecla HDMI: preencher **Comando da entrada** faz a troca funcionar. Resultado: ______ +- [ ] Escolher "Não controlar": os campos somem e nada é enviado à TV, e **Jogar agora** não demora mais do que antes (nenhuma linha `TV:` no log). Resultado: ______ +- [ ] O arquivo `adbkey.pem` na pasta de dados começa com `dpapi:` (não com `-----BEGIN`). Copiar a pasta de dados para outro usuário do Windows: o log mostra "chave ADB de outro usuário/PC; criando outra" e a TV pede permissão de novo. Resultado: ______ +- [ ] TV desligada da tomada: **Jogar agora** espera no máximo ~30 s pela TV e segue (o log mostra o tempo). Resultado: ______ + ## 4. Regressões - [ ] Interface Desktop: mapa de telas, `Segmented`, chips, tour de 3 passos e Ajustes continuam como antes. Resultado: ______ diff --git a/src/ConsoleMode/ConsoleMode.csproj b/src/ConsoleMode/ConsoleMode.csproj index 70daee4..6e15a79 100644 --- a/src/ConsoleMode/ConsoleMode.csproj +++ b/src/ConsoleMode/ConsoleMode.csproj @@ -52,6 +52,7 @@ + diff --git a/src/ConsoleMode/Models/Models.cs b/src/ConsoleMode/Models/Models.cs index e9a77aa..a8d222e 100644 --- a/src/ConsoleMode/Models/Models.cs +++ b/src/ConsoleMode/Models/Models.cs @@ -78,11 +78,41 @@ public sealed class AppConfig /// A version the user chose to skip; newer ones are still announced. public string SkippedUpdateVersion { get; set; } = ""; + /// Turning the TV on / to the PC's input when console mode starts (issue #75). + public TvControlConfig Tv { get; set; } = new(); + [JsonIgnore] public string SetupKey => $"{FocusMonitor}|{(MonitorModes.TryGetValue(FocusMonitor, out var mode) ? mode.Key : "current")}"; } +public sealed class TvControlConfig +{ + public const string None = "none"; + public const string AndroidTv = "androidTv"; + + /// "none" | "androidTv". + public string Provider { get; set; } = None; + + /// The TV's IP address or host name, optionally with ":port". + public string Host { get; set; } = ""; + + /// For Wake-on-LAN when the TV is in deep standby; empty = don't send it. + public string MacAddress { get; set; } = ""; + + /// HDMI input the PC is plugged into (1-4). + public int HdmiInput { get; set; } = 1; + + /// Android TV: shell command that switches to the PC's input, for TVs that ignore the HDMI key codes. + public string InputCommand { get; set; } = ""; + + /// Put the TV in standby after the desk is restored. Off by default. + public bool TurnOffOnRestore { get; set; } + + [JsonIgnore] + public bool IsEnabled => !string.IsNullOrWhiteSpace(Provider) && Provider != None; +} + public sealed class SavedDisplayMode { public int Width { get; set; } @@ -258,6 +288,8 @@ public sealed class ScreenRect public sealed class ConsoleRuntimeState { public bool IsActive { get; set; } + /// TV control used for this session, for the optional standby on restore. + public TvControlConfig? Tv { get; set; } public bool ShouldExit { get; set; } public bool RestoreInProgress { get; set; } public bool SteamMoved { get; set; } diff --git a/src/ConsoleMode/Resources/Strings.en-US.json b/src/ConsoleMode/Resources/Strings.en-US.json index 7694cf2..3c27bb8 100644 --- a/src/ConsoleMode/Resources/Strings.en-US.json +++ b/src/ConsoleMode/Resources/Strings.en-US.json @@ -142,6 +142,32 @@ "TvConfirmationCard": "TV confirmation", "TvConfirmationDescription": "The first time you use a new gaming display, the TV asks you to confirm that you can see the image. Respond with a mouse, keyboard, or controller (Xbox: A/B · PlayStation: ✕/○). Test it here without changing your displays.", "TestNow": "Test now", + "TvSection": "TV", + "TvControlCard": "Turn the TV on and switch input", + "TvProviderNone": "Don't control it", + "TvProviderAndroid": "Google TV / Android TV (network)", + "TvNoneDescription": "When console mode starts, the app turns the TV on and switches it to the PC's input; on restore it can put it in standby.", + "TvAndroidDescription": "Over the network, nothing to install. On the TV: Settings → System → Developer options → turn on \"USB debugging\" (or \"Network debugging\"). The first time, accept \"Allow debugging\" on the TV.", + "TvHostCard": "TV address", + "TvHostDescription": "The TV's IP on your network (Settings → Network). Tip: reserve it in your router so it doesn't change.", + "TvMacCard": "MAC address (optional)", + "TvMacDescription": "To wake the TV from deep standby (Wake-on-LAN). Leave it empty if the TV answers while off.", + "TvHdmiCard": "PC input", + "TvHdmiDescription": "The TV's HDMI port the PC is plugged into.", + "TvInputCommandCard": "Input command (advanced)", + "TvInputCommandDescription": "Only if the TV ignores the HDMI key: an Android shell command that switches to the PC's input. Empty = the HDMI key chosen above.", + "TvTurnOffCard": "Put the TV in standby on restore", + "TvTurnOffDescription": "After the desk screens are back.", + "TvTestCard": "Test the TV", + "TvTestDescription": "Turns the TV on and switches to the PC's input now, without touching the screens. The first time, it pairs with the TV.", + "TvTesting": "Talking to the TV… If it asks, accept on the TV screen.", + "TvTestSuccess": "The TV answered: it should now be on, on the PC's input.", + "TvTestFailure": "Could not talk to the TV: {0}", + "TvNotConfigured": "Choose how to control the TV.", + "TvHostMissing": "Enter the TV's IP address.", + "TvUnreachable": "The TV at {0} didn't answer. Check the IP, that the TV is on the same network and that debugging is on.", + "TvAdbNotAllowed": "The TV didn't allow this PC. Press Test again and choose \"Always allow\" + \"Allow\" on the TV.", + "TvAdbTlsUnsupported": "The TV only accepts \"Wireless debugging\" (pairing with a code). Turn on \"USB debugging\" / \"Network debugging\" in Developer options.", "ControllerTestCard": "Test controller", "ControllerTestDescription": "Shows live what Windows delivers from each controller: source, buttons by index, D-pad and sticks. Use it when a pad shows up but does nothing.", "ControllerTestSteamHint": "Steam running with PlayStation support captures the pad and the app gets nothing. Close Steam (or turn off PlayStation support in Steam Input) and test again.", diff --git a/src/ConsoleMode/Resources/Strings.es-ES.json b/src/ConsoleMode/Resources/Strings.es-ES.json index 1a422da..8e2e358 100644 --- a/src/ConsoleMode/Resources/Strings.es-ES.json +++ b/src/ConsoleMode/Resources/Strings.es-ES.json @@ -142,6 +142,32 @@ "TvConfirmationCard": "Confirmación en la TV", "TvConfirmationDescription": "La primera vez con una pantalla de juego nueva, la TV pide que confirmes que ves la imagen. Responde con ratón, teclado o mando (Xbox: A/B · PlayStation: ✕/○). Pruébalo aquí sin cambiar tus pantallas.", "TestNow": "Probar ahora", + "TvSection": "TV", + "TvControlCard": "Encender la TV y cambiar la entrada", + "TvProviderNone": "No controlarla", + "TvProviderAndroid": "Google TV / Android TV (red)", + "TvNoneDescription": "Al entrar en el modo consola, la app enciende la TV y cambia a la entrada del PC; al restaurar, puede ponerla en reposo.", + "TvAndroidDescription": "Por la red, sin instalar nada. En la TV: Ajustes → Sistema → Opciones de desarrollador → activa \"Depuración USB\" (o \"Depuración por red\"). La primera vez, acepta \"Permitir depuración\" en la TV.", + "TvHostCard": "Dirección de la TV", + "TvHostDescription": "IP de la TV en tu red (Ajustes → Red). Consejo: resérvala en el router para que no cambie.", + "TvMacCard": "Dirección MAC (opcional)", + "TvMacDescription": "Para despertar la TV del reposo profundo (Wake-on-LAN). Déjalo vacío si la TV responde apagada.", + "TvHdmiCard": "Entrada del PC", + "TvHdmiDescription": "Puerto HDMI de la TV donde está conectado el PC.", + "TvInputCommandCard": "Comando de la entrada (avanzado)", + "TvInputCommandDescription": "Solo si la TV ignora la tecla HDMI: un comando de shell de Android que cambia a la entrada del PC. Vacío = la tecla HDMI elegida arriba.", + "TvTurnOffCard": "Poner la TV en reposo al restaurar", + "TvTurnOffDescription": "Después de que vuelvan las pantallas del escritorio.", + "TvTestCard": "Probar la TV", + "TvTestDescription": "Enciende la TV y cambia a la entrada del PC ahora, sin tocar las pantallas. La primera vez, se empareja con la TV.", + "TvTesting": "Hablando con la TV… Si lo pide, acepta en la pantalla de la TV.", + "TvTestSuccess": "La TV respondió: debería estar encendida en la entrada del PC.", + "TvTestFailure": "No se pudo hablar con la TV: {0}", + "TvNotConfigured": "Elige cómo controlar la TV.", + "TvHostMissing": "Indica la dirección IP de la TV.", + "TvUnreachable": "La TV en {0} no respondió. Revisa la IP, que la TV esté en la misma red y que la depuración esté activada.", + "TvAdbNotAllowed": "La TV no autorizó este PC. Pulsa Probar de nuevo y elige \"Permitir siempre\" + \"Permitir\" en la TV.", + "TvAdbTlsUnsupported": "La TV solo acepta \"Depuración inalámbrica\" (emparejamiento con código). Activa \"Depuración USB\" / \"Depuración por red\" en Opciones de desarrollador.", "ControllerTestCard": "Probar mando", "ControllerTestDescription": "Muestra en vivo lo que Windows entrega de cada mando: origen, botones por índice, cruceta y sticks. Úsalo si el mando aparece pero no responde.", "ControllerTestSteamHint": "Steam abierto con soporte para PlayStation captura el mando y la aplicación no recibe nada. Cierra Steam (o desactiva el soporte para PlayStation en Steam Input) y prueba de nuevo.", diff --git a/src/ConsoleMode/Resources/Strings.pt-BR.json b/src/ConsoleMode/Resources/Strings.pt-BR.json index b70c1f7..7978a40 100644 --- a/src/ConsoleMode/Resources/Strings.pt-BR.json +++ b/src/ConsoleMode/Resources/Strings.pt-BR.json @@ -142,6 +142,32 @@ "TvConfirmationCard": "Confirmação na TV", "TvConfirmationDescription": "Na primeira vez com uma tela de jogo nova, a TV pergunta se você está vendo a imagem. Responda com mouse, teclado ou controle (Xbox: A/B · PlayStation: ✕/○). Teste aqui sem mexer nas telas.", "TestNow": "Testar agora", + "TvSection": "TV", + "TvControlCard": "Ligar a TV e trocar a entrada", + "TvProviderNone": "Não controlar", + "TvProviderAndroid": "Google TV / Android TV (rede)", + "TvNoneDescription": "Ao entrar no modo console, o app liga a TV e troca para a entrada do PC; ao restaurar, pode colocá-la em espera.", + "TvAndroidDescription": "Pela rede, sem instalar nada. Na TV: Configurações → Sistema → Opções do desenvolvedor → ative \"Depuração USB\" (ou \"Depuração pela rede\"). Na primeira vez, aceite \"Permitir depuração\" na TV.", + "TvHostCard": "Endereço da TV", + "TvHostDescription": "IP da TV na sua rede (Configurações → Rede). Dica: reserve esse IP no roteador para ele não mudar.", + "TvMacCard": "Endereço MAC (opcional)", + "TvMacDescription": "Para acordar a TV em espera profunda (Wake-on-LAN). Deixe vazio se a TV responde mesmo desligada.", + "TvHdmiCard": "Entrada do PC", + "TvHdmiDescription": "Porta HDMI da TV onde o PC está ligado.", + "TvInputCommandCard": "Comando da entrada (avançado)", + "TvInputCommandDescription": "Só se a TV ignorar a tecla HDMI: um comando de shell do Android que troca para a entrada do PC. Vazio = tecla HDMI escolhida acima.", + "TvTurnOffCard": "Colocar a TV em espera ao restaurar", + "TvTurnOffDescription": "Depois que as telas da mesa voltam.", + "TvTestCard": "Testar a TV", + "TvTestDescription": "Liga a TV e troca para a entrada do PC agora, sem mexer nas telas. Na primeira vez, pareia com a TV.", + "TvTesting": "Falando com a TV… Se ela pedir, aceite na tela da TV.", + "TvTestSuccess": "A TV respondeu: ela deve estar ligada na entrada do PC.", + "TvTestFailure": "Não foi possível falar com a TV: {0}", + "TvNotConfigured": "Escolha como controlar a TV.", + "TvHostMissing": "Informe o endereço IP da TV.", + "TvUnreachable": "A TV em {0} não respondeu. Confira o IP, se a TV está na mesma rede e se a depuração está ativada.", + "TvAdbNotAllowed": "A TV não autorizou este PC. Toque em Testar de novo e escolha \"Sempre permitir\" + \"Permitir\" na TV.", + "TvAdbTlsUnsupported": "A TV só aceita \"Depuração sem fio\" (com pareamento por código). Ative \"Depuração USB\" / \"Depuração pela rede\" nas Opções do desenvolvedor.", "ControllerTestCard": "Testar controle", "ControllerTestDescription": "Mostra ao vivo o que o Windows entrega de cada controle: origem, botões por índice, D-pad e analógicos. Use se o controle aparece mas não responde.", "ControllerTestSteamHint": "Steam aberto com suporte a PlayStation captura o controle e o app não recebe nada. Feche o Steam (ou desligue o suporte a PlayStation no Steam Input) e teste de novo.", diff --git a/src/ConsoleMode/Services/ConsoleEngine.cs b/src/ConsoleMode/Services/ConsoleEngine.cs index b7a0eca..3d94fee 100644 --- a/src/ConsoleMode/Services/ConsoleEngine.cs +++ b/src/ConsoleMode/Services/ConsoleEngine.cs @@ -1,5 +1,6 @@ using ConsoleMode.Models; using ConsoleMode.Native; +using ConsoleMode.Services.Tv; namespace ConsoleMode.Services; @@ -11,6 +12,7 @@ public sealed class ConsoleEngine public RtssService Rtss { get; } = new(); public LaunchService Launch { get; } = new(); public VideoFeaturesService Video { get; } = new(); + public TvControlService Tv { get; } = new(); public const string AudioOnConnectId = "__on_connect__"; @@ -65,6 +67,11 @@ public void Start(AppConfig config, MonitorInfo? focusInfo, Func m.Name == config.FocusMonitor) ?? focusInfo; @@ -297,6 +304,7 @@ public void Stop() Rtss.Restore(State); Monitors.ClearCache(); Audio.ClearCache(); + if (State.Tv is { IsEnabled: true, TurnOffOnRestore: true } tv) Tv.TurnOff(tv); AppLog.Write("Stop-ConsoleMode: restauração concluída"); } catch (Exception ex) @@ -309,6 +317,7 @@ public void Stop() State.RestoreInProgress = false; State.IsActive = false; State.ShouldExit = false; + State.Tv = null; State.SteamMoved = false; State.MoveCount = 0; State.HasAppeared = false; @@ -425,7 +434,8 @@ private AppConfig ResolveMonitorNames(AppConfig config) InterfaceSounds = config.InterfaceSounds, ConsoleBackground = config.ConsoleBackground, ConsoleBackgroundImage = config.ConsoleBackgroundImage, - SkippedUpdateVersion = config.SkippedUpdateVersion + SkippedUpdateVersion = config.SkippedUpdateVersion, + Tv = config.Tv }; } diff --git a/src/ConsoleMode/Services/LocalizationService.cs b/src/ConsoleMode/Services/LocalizationService.cs index 8e3543a..0411804 100644 --- a/src/ConsoleMode/Services/LocalizationService.cs +++ b/src/ConsoleMode/Services/LocalizationService.cs @@ -223,6 +223,20 @@ internal void NotifyAll() public string TvConfirmationCard => LocalizationService.Get(nameof(TvConfirmationCard)); public string TvConfirmationDescription => LocalizationService.Get(nameof(TvConfirmationDescription)); public string TestNow => LocalizationService.Get(nameof(TestNow)); + public string TvSection => LocalizationService.Get(nameof(TvSection)); + public string TvControlCard => LocalizationService.Get(nameof(TvControlCard)); + public string TvHostCard => LocalizationService.Get(nameof(TvHostCard)); + public string TvHostDescription => LocalizationService.Get(nameof(TvHostDescription)); + public string TvMacCard => LocalizationService.Get(nameof(TvMacCard)); + public string TvMacDescription => LocalizationService.Get(nameof(TvMacDescription)); + public string TvHdmiCard => LocalizationService.Get(nameof(TvHdmiCard)); + public string TvHdmiDescription => LocalizationService.Get(nameof(TvHdmiDescription)); + public string TvInputCommandCard => LocalizationService.Get(nameof(TvInputCommandCard)); + public string TvInputCommandDescription => LocalizationService.Get(nameof(TvInputCommandDescription)); + public string TvTurnOffCard => LocalizationService.Get(nameof(TvTurnOffCard)); + public string TvTurnOffDescription => LocalizationService.Get(nameof(TvTurnOffDescription)); + public string TvTestCard => LocalizationService.Get(nameof(TvTestCard)); + public string TvTestDescription => LocalizationService.Get(nameof(TvTestDescription)); public string TutorialCard => LocalizationService.Get(nameof(TutorialCard)); public string TutorialDescription => LocalizationService.Get(nameof(TutorialDescription)); public string SeeAgain => LocalizationService.Get(nameof(SeeAgain)); diff --git a/src/ConsoleMode/Services/SecretProtector.cs b/src/ConsoleMode/Services/SecretProtector.cs new file mode 100644 index 0000000..92e8bee --- /dev/null +++ b/src/ConsoleMode/Services/SecretProtector.cs @@ -0,0 +1,55 @@ +using System.Security.Cryptography; +using System.Text; + +namespace ConsoleMode.Services; + +/// +/// Secrets kept on disk (TV tokens and keys), encrypted for the signed-in Windows user with DPAPI. +/// The data folder travels with the portable exe, so plain text would travel with it too. +/// DPAPI data can only be read by the same Windows user on the same PC: anything else +/// comes back as "unreadable" so the caller can ask the user to enter it again. +/// +public static class SecretProtector +{ + public const string Prefix = "dpapi:"; + + public static bool IsProtected(string? stored) => + stored is not null && stored.StartsWith(Prefix, StringComparison.Ordinal); + + public static string Protect(string secret) + { + if (string.IsNullOrEmpty(secret)) return ""; + var data = ProtectedData.Protect(Encoding.UTF8.GetBytes(secret), null, DataProtectionScope.CurrentUser); + return Prefix + Convert.ToBase64String(data); + } + + /// + /// Plain text (never protected, e.g. a hand-edited config) passes through. Returns false when + /// the data is protected but can't be read here (another Windows user or another PC). + /// + public static bool TryUnprotect(string? stored, out string secret) + { + secret = ""; + if (string.IsNullOrEmpty(stored)) return true; + if (!IsProtected(stored)) + { + secret = stored; + return true; + } + + try + { + var data = ProtectedData.Unprotect(Convert.FromBase64String(stored[Prefix.Length..]), null, DataProtectionScope.CurrentUser); + secret = Encoding.UTF8.GetString(data); + return true; + } + catch (Exception ex) when (ex is CryptographicException or FormatException) + { + AppLog.Write($"Segredo protegido ilegível neste usuário/PC ({ex.GetType().Name})"); + return false; + } + } + + /// Like , with "" for an unreadable secret. + public static string Unprotect(string? stored) => TryUnprotect(stored, out var secret) ? secret : ""; +} diff --git a/src/ConsoleMode/Services/Tv/AdbClient.cs b/src/ConsoleMode/Services/Tv/AdbClient.cs new file mode 100644 index 0000000..0feb419 --- /dev/null +++ b/src/ConsoleMode/Services/Tv/AdbClient.cs @@ -0,0 +1,205 @@ +using System.Net.Sockets; +using System.Security.Cryptography; +using System.Text; + +namespace ConsoleMode.Services.Tv; + +/// +/// Minimal ADB client over TCP ("ADB debugging" / "network debugging" on the TV, port 5555): +/// connect, authenticate with this PC's key and run shell commands. +/// The first time, the TV asks "Allow debugging from this computer?"; with "Always allow" +/// ticked the key is remembered and later connections go straight through. +/// +public sealed class AdbClient : IAsyncDisposable +{ + private readonly TcpClient _tcp; + private readonly NetworkStream _stream; + private uint _nextLocalId = 1; + + private AdbClient(TcpClient tcp) + { + _tcp = tcp; + _stream = tcp.GetStream(); + } + + /// How long to wait for the user to accept the prompt on the TV. + public static async Task ConnectAsync(string host, int port, TimeSpan approvalTimeout, CancellationToken ct) + { + var tcp = new TcpClient { NoDelay = true }; + try + { + using (var connect = CancellationTokenSource.CreateLinkedTokenSource(ct)) + { + connect.CancelAfter(TimeSpan.FromSeconds(3)); + try + { + await tcp.ConnectAsync(host, port, connect.Token); + } + catch (OperationCanceledException) when (!ct.IsCancellationRequested) + { + throw new SocketException((int)SocketError.TimedOut); + } + } + var client = new AdbClient(tcp); + await client.HandshakeAsync(approvalTimeout, ct); + return client; + } + catch + { + tcp.Dispose(); + throw; + } + } + + public async Task ShellAsync(string command, CancellationToken ct) + { + var localId = _nextLocalId++; + uint remoteId = 0; + var output = new AdbShellOutput(); + await SendAsync(AdbProtocol.Open, localId, 0, AdbProtocol.ShellService(command), ct); + + while (true) + { + var message = await ReadAsync(ct); + if (message.Arg1 != localId) continue; + switch (message.Command) + { + case AdbProtocol.Okay: + remoteId = message.Arg0; + break; + case AdbProtocol.Write: + output.Append(message.Data); + await SendAsync(AdbProtocol.Okay, localId, message.Arg0, null, ct); + break; + case AdbProtocol.Close: + if (remoteId != 0) await SendAsync(AdbProtocol.Close, localId, remoteId, null, ct); + return output.ToString(); + } + } + } + + public ValueTask DisposeAsync() + { + _tcp.Dispose(); + return ValueTask.CompletedTask; + } + + private async Task HandshakeAsync(TimeSpan approvalTimeout, CancellationToken ct) + { + using var key = AdbKeyStore.LoadOrCreate(); + await SendAsync(AdbProtocol.Connect, AdbProtocol.Version, AdbProtocol.MaxPayload, AdbProtocol.ConnectBanner(), ct); + + var sentSignature = false; + var sentPublicKey = false; + while (true) + { + // After sending our public key the TV waits for someone to press "Allow". + using var wait = CancellationTokenSource.CreateLinkedTokenSource(ct); + wait.CancelAfter(sentPublicKey ? approvalTimeout : TimeSpan.FromSeconds(5)); + AdbProtocol.Message message; + try + { + message = await ReadAsync(wait.Token); + } + catch (OperationCanceledException) when (!ct.IsCancellationRequested) + { + if (sentPublicKey) throw new TvControlException(LocalizationService.Get("TvAdbNotAllowed")); + throw new IOException("ADB: a TV não respondeu ao handshake"); + } + + switch (message.Command) + { + case AdbProtocol.Connect: + return; + case AdbProtocol.StartTls: + // "Wireless debugging" (Android 11+ pairing) wraps ADB in TLS; the classic + // "ADB debugging"/"network debugging" on port 5555 doesn't. + throw new TvControlException(LocalizationService.Get("TvAdbTlsUnsupported")); + case AdbProtocol.Auth when message.Arg0 == AdbProtocol.AuthToken: + if (!sentSignature) + { + await SendAsync(AdbProtocol.Auth, AdbProtocol.AuthSignature, 0, AdbProtocol.SignToken(key, message.Data), ct); + sentSignature = true; + } + else if (!sentPublicKey) + { + AppLog.Write("TV: chave deste PC ainda não autorizada; aguardando \"Permitir\" na TV"); + var publicKey = AdbProtocol.EncodePublicKey(key.ExportParameters(false), $"ConsoleMode@{Environment.MachineName}"); + await SendAsync(AdbProtocol.Auth, AdbProtocol.AuthRsaPublicKey, 0, publicKey, ct); + sentPublicKey = true; + } + else + { + throw new TvControlException(LocalizationService.Get("TvAdbNotAllowed")); + } + break; + } + } + } + + private async Task SendAsync(uint command, uint arg0, uint arg1, byte[]? data, CancellationToken ct) => + await _stream.WriteAsync(AdbProtocol.Encode(command, arg0, arg1, data), ct); + + private async Task ReadAsync(CancellationToken ct) + { + var header = new byte[AdbProtocol.HeaderSize]; + await _stream.ReadExactlyAsync(header, ct); + var (command, arg0, arg1, length, checksum) = AdbProtocol.DecodeHeader(header); + var data = new byte[length]; + if (length > 0) await _stream.ReadExactlyAsync(data, ct); + AdbProtocol.ValidatePayload(checksum, data); + return new AdbProtocol.Message(command, arg0, arg1, data); + } +} + +/// +/// This PC's ADB key (RSA-2048), kept next to the config so the TV only asks once. +/// The private key is encrypted with DPAPI (see ): the portable +/// data folder travels with the exe, and the key lets whoever has it control the paired TV. +/// +internal static class AdbKeyStore +{ + private static readonly object Gate = new(); + + public static RSA LoadOrCreate() + { + lock (Gate) + { + var path = Path.Combine(AppPaths.DataDir, "adbkey.pem"); + if (File.Exists(path)) + { + var saved = RSA.Create(); + try + { + var stored = File.ReadAllText(path).Trim(); + if (SecretProtector.TryUnprotect(stored, out var pem) && pem.Length > 0) + { + saved.ImportFromPem(pem); + if (saved.KeySize == 2048) + { + // A key written before encryption existed: protect it in place. + if (!SecretProtector.IsProtected(stored)) File.WriteAllText(path, SecretProtector.Protect(pem)); + return saved; + } + } + else + { + // Copied from another Windows user / PC: DPAPI can't open it. A new key just + // means the TV asks "Allow debugging?" once more. + AppLog.Write("TV: chave ADB de outro usuário/PC; criando outra (a TV pedirá permissão de novo)"); + } + } + catch (Exception ex) when (ex is CryptographicException or ArgumentException or IOException) + { + AppLog.Write($"TV: chave ADB ilegível, criando outra: {ex.Message}"); + } + saved.Dispose(); + } + + var created = RSA.Create(2048); + File.WriteAllText(path, SecretProtector.Protect(created.ExportPkcs8PrivateKeyPem())); + AppLog.Write("TV: chave ADB criada"); + return created; + } + } +} diff --git a/src/ConsoleMode/Services/Tv/AdbProtocol.cs b/src/ConsoleMode/Services/Tv/AdbProtocol.cs new file mode 100644 index 0000000..5687ddf --- /dev/null +++ b/src/ConsoleMode/Services/Tv/AdbProtocol.cs @@ -0,0 +1,147 @@ +using System.Buffers.Binary; +using System.Numerics; +using System.Security.Cryptography; +using System.Text; + +namespace ConsoleMode.Services.Tv; + +/// +/// The pieces of the ADB wire protocol (the one adb connect speaks to port 5555) that +/// Console Mode needs to send key presses to an Android TV / Google TV, without shipping adb.exe. +/// Pure logic: framing, the auth signature and Android's public key format. +/// +public static class AdbProtocol +{ + public const uint Connect = 0x4e584e43; // CNXN + public const uint Auth = 0x48545541; // AUTH + public const uint Open = 0x4e45504f; // OPEN + public const uint Okay = 0x59414b4f; // OKAY + public const uint Close = 0x45534c43; // CLSE + public const uint Write = 0x45545257; // WRTE + public const uint StartTls = 0x534c5453; // STLS + + public const uint AuthToken = 1; + public const uint AuthSignature = 2; + public const uint AuthRsaPublicKey = 3; + + /// Version with the payload checksum, which every device still accepts. + public const uint Version = 0x01000000; + public const uint MaxPayload = 256 * 1024; + public const int MaxShellOutput = 1024 * 1024; + public const int HeaderSize = 24; + public const int DefaultPort = 5555; + + // android.view.KeyEvent codes. + public const int KeyWakeUp = 224; + public const int KeySleep = 223; + public const int KeyTvInputHdmi1 = 243; + + public readonly record struct Message(uint Command, uint Arg0, uint Arg1, byte[] Data); + + public static byte[] Encode(uint command, uint arg0, uint arg1, byte[]? data = null) + { + data ??= []; + var buffer = new byte[HeaderSize + data.Length]; + var span = buffer.AsSpan(); + BinaryPrimitives.WriteUInt32LittleEndian(span[0..], command); + BinaryPrimitives.WriteUInt32LittleEndian(span[4..], arg0); + BinaryPrimitives.WriteUInt32LittleEndian(span[8..], arg1); + BinaryPrimitives.WriteUInt32LittleEndian(span[12..], (uint)data.Length); + BinaryPrimitives.WriteUInt32LittleEndian(span[16..], Checksum(data)); + BinaryPrimitives.WriteUInt32LittleEndian(span[20..], command ^ 0xFFFFFFFF); + data.CopyTo(buffer, HeaderSize); + return buffer; + } + + /// Reads a header; returns the command, args and payload length. + public static (uint Command, uint Arg0, uint Arg1, int Length, uint Checksum) DecodeHeader(ReadOnlySpan header) + { + if (header.Length < HeaderSize) throw new InvalidDataException("ADB: cabeçalho curto"); + var command = BinaryPrimitives.ReadUInt32LittleEndian(header); + var magic = BinaryPrimitives.ReadUInt32LittleEndian(header[20..]); + if (magic != (command ^ 0xFFFFFFFF)) throw new InvalidDataException("ADB: cabeçalho inválido"); + var length = BinaryPrimitives.ReadUInt32LittleEndian(header[12..]); + if (length > MaxPayload) throw new InvalidDataException("ADB: mensagem grande demais"); + return (command, + BinaryPrimitives.ReadUInt32LittleEndian(header[4..]), + BinaryPrimitives.ReadUInt32LittleEndian(header[8..]), + (int)length, + BinaryPrimitives.ReadUInt32LittleEndian(header[16..])); + } + + public static void ValidatePayload(uint expectedChecksum, ReadOnlySpan data) + { + if (Checksum(data) != expectedChecksum) + throw new InvalidDataException("ADB: checksum do pacote inválido"); + } + + public static uint Checksum(ReadOnlySpan data) + { + uint sum = 0; + foreach (var b in data) sum += b; + return sum; + } + + /// "host::" banner sent with CNXN. + public static byte[] ConnectBanner() => Encoding.ASCII.GetBytes("host::\0"); + + public static byte[] ShellService(string command) => Encoding.UTF8.GetBytes($"shell:{command}\0"); + + /// + /// adb signs the 20-byte token as if it were a SHA-1 digest (RSA_sign with NID_sha1), + /// i.e. PKCS#1 v1.5 over the SHA-1 DigestInfo of the raw token. + /// + public static byte[] SignToken(RSA key, byte[] token) => + key.SignHash(token, HashAlgorithmName.SHA1, RSASignaturePadding.Pkcs1); + + /// + /// Android's RSAPublicKey struct (android_pubkey_encode), base64 encoded, followed by + /// " comment" and a NUL: what the TV shows in "Allow debugging from this computer?". + /// + public static byte[] EncodePublicKey(RSAParameters key, string comment) + { + var modulus = key.Modulus ?? throw new ArgumentException("modulus"); + var exponent = key.Exponent ?? throw new ArgumentException("exponent"); + if (modulus.Length != 256) throw new ArgumentException("ADB keys are RSA-2048"); + + var n = new BigInteger(modulus, isUnsigned: true, isBigEndian: true); + var n0 = BinaryPrimitives.ReadUInt32BigEndian(modulus.AsSpan(modulus.Length - 4)); + // Newton's iteration for the inverse of an odd number mod 2^32. + var inverse = n0; + for (var i = 0; i < 5; i++) inverse = unchecked(inverse * (2 - n0 * inverse)); + var n0Inverse = unchecked(0u - inverse); + var rr = BigInteger.ModPow(2, 2 * 2048, n); + + var buffer = new byte[4 + 4 + 256 + 256 + 4]; + var span = buffer.AsSpan(); + BinaryPrimitives.WriteUInt32LittleEndian(span, 256 / 4); + BinaryPrimitives.WriteUInt32LittleEndian(span[4..], n0Inverse); + WriteLittleEndian(n, span.Slice(8, 256)); + WriteLittleEndian(rr, span.Slice(264, 256)); + uint e = 0; + foreach (var b in exponent) e = (e << 8) | b; + BinaryPrimitives.WriteUInt32LittleEndian(span[520..], e); + + return Encoding.ASCII.GetBytes($"{Convert.ToBase64String(buffer)} {comment}\0"); + } + + /// "host" or "host:port" (default 5555). + public static (string Host, int Port) ParseEndpoint(string value) + { + var text = value.Trim(); + var colon = text.LastIndexOf(':'); + if (colon > 0 && text.IndexOf(':') == colon && int.TryParse(text[(colon + 1)..], out var port) && port is > 0 and < 65536) + return (text[..colon], port); + return (text, DefaultPort); + } + + /// Key code for "HDMI n" (1-4). + public static int HdmiKey(int input) => KeyTvInputHdmi1 + Math.Clamp(input, 1, 4) - 1; + + private static void WriteLittleEndian(BigInteger value, Span destination) + { + destination.Clear(); + var bytes = value.ToByteArray(isUnsigned: true, isBigEndian: false); + bytes.AsSpan(0, Math.Min(bytes.Length, destination.Length)).CopyTo(destination); + } +} diff --git a/src/ConsoleMode/Services/Tv/AdbShellOutput.cs b/src/ConsoleMode/Services/Tv/AdbShellOutput.cs new file mode 100644 index 0000000..dd94c3a --- /dev/null +++ b/src/ConsoleMode/Services/Tv/AdbShellOutput.cs @@ -0,0 +1,21 @@ +using System.Text; + +namespace ConsoleMode.Services.Tv; + +/// Accumulates bounded output from a remote ADB shell command. +public sealed class AdbShellOutput +{ + private readonly StringBuilder _text = new(); + private int _bytes; + + public void Append(byte[] data) + { + if (data.Length > AdbProtocol.MaxShellOutput - _bytes) + throw new InvalidDataException("ADB: saída do comando excede o limite de 1 MiB"); + + _bytes += data.Length; + _text.Append(Encoding.UTF8.GetString(data)); + } + + public override string ToString() => _text.ToString(); +} diff --git a/src/ConsoleMode/Services/Tv/AndroidTvController.cs b/src/ConsoleMode/Services/Tv/AndroidTvController.cs new file mode 100644 index 0000000..61d0243 --- /dev/null +++ b/src/ConsoleMode/Services/Tv/AndroidTvController.cs @@ -0,0 +1,37 @@ +using ConsoleMode.Models; + +namespace ConsoleMode.Services.Tv; + +/// +/// Android TV / Google TV (TCL, Sony, Hisense, Philips…) over the network with ADB: +/// wake up, then the "HDMI n" key (or a custom command). Needs Developer options → +/// "USB debugging" / "Network debugging" on the TV; nothing to install on the PC. +/// +public sealed class AndroidTvController : ITvController +{ + public async Task TurnOnAsync(TvControlConfig config, TimeSpan approvalTimeout, CancellationToken ct) + { + await using var adb = await ConnectAsync(config, approvalTimeout, wakeOnLan: true, ct); + await adb.ShellAsync($"input keyevent {AdbProtocol.KeyWakeUp}", ct); + // Right after waking, some TVs drop the input key while the launcher loads. + await Task.Delay(1500, ct); + var inputCommand = string.IsNullOrWhiteSpace(config.InputCommand) + ? $"input keyevent {AdbProtocol.HdmiKey(config.HdmiInput)}" + : config.InputCommand.Trim(); + await adb.ShellAsync(inputCommand, ct); + } + + public async Task TurnOffAsync(TvControlConfig config, CancellationToken ct) + { + await using var adb = await ConnectAsync(config, TimeSpan.FromSeconds(5), wakeOnLan: false, ct); + await adb.ShellAsync($"input keyevent {AdbProtocol.KeySleep}", ct); + } + + private static Task ConnectAsync(TvControlConfig config, TimeSpan approvalTimeout, bool wakeOnLan, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(config.Host)) + throw new TvControlException(LocalizationService.Get("TvHostMissing")); + var (host, port) = AdbProtocol.ParseEndpoint(config.Host); + return TvNetwork.ConnectAsync(config, wakeOnLan, token => AdbClient.ConnectAsync(host, port, approvalTimeout, token), ct); + } +} diff --git a/src/ConsoleMode/Services/Tv/TvControlService.cs b/src/ConsoleMode/Services/Tv/TvControlService.cs new file mode 100644 index 0000000..995df82 --- /dev/null +++ b/src/ConsoleMode/Services/Tv/TvControlService.cs @@ -0,0 +1,75 @@ +using ConsoleMode.Models; + +namespace ConsoleMode.Services.Tv; + +/// One way of reaching the TV (network API, CEC adapter…). +public interface ITvController +{ + /// Turns the TV on and switches it to the PC's HDMI input. + Task TurnOnAsync(TvControlConfig config, TimeSpan approvalTimeout, CancellationToken ct); + + /// Puts the TV in standby. + Task TurnOffAsync(TvControlConfig config, CancellationToken ct); +} + +/// A failure worth showing to the user as is (already localized). +public sealed class TvControlException(string message) : Exception(message); + +/// +/// Turns the TV on / to the PC's input when console mode starts and, optionally, puts it in +/// standby after the restore. A TV that doesn't answer never blocks or breaks console mode: +/// the engine only logs, and its own "wait for the game screen" check does the rest. +/// +public sealed class TvControlService +{ + /// Enough for a TV waking from deep standby (Wake-on-LAN) to answer. + private static readonly TimeSpan StartTimeout = TimeSpan.FromSeconds(30); + private static readonly TimeSpan StopTimeout = TimeSpan.FromSeconds(15); + + /// Settings → Test: time to press "Allow" on the TV the first time. + public static readonly TimeSpan PairingTimeout = TimeSpan.FromSeconds(60); + + public static ITvController? Create(string provider) => provider switch + { + TvControlConfig.AndroidTv => new AndroidTvController(), + _ => null + }; + + /// Settings → Test. Throws with a message for the user. + public async Task TestAsync(TvControlConfig config, CancellationToken ct) + { + var controller = Create(config.Provider) ?? throw new TvControlException(LocalizationService.Get("TvNotConfigured")); + AppLog.Write($"TV: teste ({config.Provider})"); + await controller.TurnOnAsync(config, PairingTimeout, ct); + } + + /// Called by the engine on its worker thread; never throws. + public void TurnOn(TvControlConfig config) => + Run(config, "ligar", StartTimeout, (c, tv, ct) => c.TurnOnAsync(tv, TimeSpan.FromSeconds(10), ct)); + + /// Called by the engine after the restore; never throws. + public void TurnOff(TvControlConfig config) => + Run(config, "desligar", StopTimeout, (c, tv, ct) => c.TurnOffAsync(tv, ct)); + + private static void Run(TvControlConfig config, string action, TimeSpan timeout, + Func work) + { + var controller = Create(config.Provider); + if (controller is null) return; + var started = DateTime.UtcNow; + try + { + using var cts = new CancellationTokenSource(timeout); + Task.Run(() => work(controller, config, cts.Token), cts.Token).GetAwaiter().GetResult(); + AppLog.Write($"TV: {action} ({config.Provider}) ok em {(DateTime.UtcNow - started).TotalSeconds:0.0}s"); + } + catch (OperationCanceledException) + { + AppLog.Write($"TV: {action} ({config.Provider}) sem resposta em {timeout.TotalSeconds:0}s"); + } + catch (Exception ex) + { + AppLog.Write($"TV: {action} ({config.Provider}) falhou: {ex.Message}"); + } + } +} diff --git a/src/ConsoleMode/Services/Tv/TvNetwork.cs b/src/ConsoleMode/Services/Tv/TvNetwork.cs new file mode 100644 index 0000000..42fea23 --- /dev/null +++ b/src/ConsoleMode/Services/Tv/TvNetwork.cs @@ -0,0 +1,56 @@ +using System.Net.Sockets; +using ConsoleMode.Models; + +namespace ConsoleMode.Services.Tv; + +/// Connecting to a TV on the network, waking it with Wake-on-LAN when it's in deep standby. +public static class TvNetwork +{ + /// A TV woken by Wake-on-LAN takes a while to bring its network (and services) up. + private static readonly TimeSpan WakeWait = TimeSpan.FromSeconds(20); + + /// One connection attempt; throws a network error when nobody answers. + /// Send the magic packet (when a MAC is set) if the first attempt fails. + public static async Task ConnectAsync(TvControlConfig config, bool wakeOnLan, + Func> connect, CancellationToken ct) + { + try + { + return await connect(ct); + } + catch (Exception ex) when (IsUnreachable(ex) && wakeOnLan && WakeOnLan.TryParseMac(config.MacAddress, out var mac)) + { + // Deep standby: no network until the magic packet wakes the TV. + AppLog.Write($"TV: {config.Host} sem resposta; enviando Wake-on-LAN"); + await WakeOnLan.SendAsync(mac, ct); + } + catch (Exception ex) when (IsUnreachable(ex)) + { + throw Unreachable(config); + } + + var deadline = DateTime.UtcNow + WakeWait; + while (true) + { + await Task.Delay(2000, ct); + try + { + return await connect(ct); + } + catch (Exception ex) when (IsUnreachable(ex) && DateTime.UtcNow < deadline) + { + // Still booting; try again. + } + catch (Exception ex) when (IsUnreachable(ex)) + { + throw Unreachable(config); + } + } + } + + /// Nothing listening, no route, connect timeout or a TV that hung up mid-handshake. + public static bool IsUnreachable(Exception ex) => ex is SocketException or IOException; + + private static TvControlException Unreachable(TvControlConfig config) => + new(LocalizationService.Get("TvUnreachable", config.Host)); +} diff --git a/src/ConsoleMode/Services/Tv/WakeOnLan.cs b/src/ConsoleMode/Services/Tv/WakeOnLan.cs new file mode 100644 index 0000000..0a6857a --- /dev/null +++ b/src/ConsoleMode/Services/Tv/WakeOnLan.cs @@ -0,0 +1,49 @@ +using System.Globalization; +using System.Net; +using System.Net.Sockets; + +namespace ConsoleMode.Services.Tv; + +/// Wake-on-LAN "magic packet": 6 × 0xFF followed by the MAC address 16 times. +public static class WakeOnLan +{ + /// Accepts AA:BB:CC:DD:EE:FF, AA-BB-CC-DD-EE-FF or AABBCCDDEEFF. + public static bool TryParseMac(string? text, out byte[] mac) + { + mac = []; + if (string.IsNullOrWhiteSpace(text)) return false; + var hex = text.Trim().Replace(":", "").Replace("-", "").Replace(".", ""); + if (hex.Length != 12) return false; + + var bytes = new byte[6]; + for (var i = 0; i < 6; i++) + { + if (!byte.TryParse(hex.AsSpan(i * 2, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out bytes[i])) + return false; + } + mac = bytes; + return true; + } + + public static byte[] BuildPacket(byte[] mac) + { + if (mac.Length != 6) throw new ArgumentException("MAC must have 6 bytes", nameof(mac)); + var packet = new byte[6 + 16 * 6]; + Array.Fill(packet, (byte)0xFF, 0, 6); + for (var i = 0; i < 16; i++) mac.CopyTo(packet, 6 + i * 6); + return packet; + } + + /// Broadcasts the packet on the usual ports (9 and 7); a few sends in case one is lost. + public static async Task SendAsync(byte[] mac, CancellationToken ct) + { + var packet = BuildPacket(mac); + using var udp = new UdpClient { EnableBroadcast = true }; + for (var attempt = 0; attempt < 3; attempt++) + { + foreach (var port in new[] { 9, 7 }) + await udp.SendAsync(packet, new IPEndPoint(IPAddress.Broadcast, port), ct); + await Task.Delay(100, ct); + } + } +} diff --git a/src/ConsoleMode/ViewModels/MainViewModel.Tv.cs b/src/ConsoleMode/ViewModels/MainViewModel.Tv.cs new file mode 100644 index 0000000..278295b --- /dev/null +++ b/src/ConsoleMode/ViewModels/MainViewModel.Tv.cs @@ -0,0 +1,120 @@ +using System.Collections.ObjectModel; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using ConsoleMode.Models; +using ConsoleMode.Services; +using ConsoleMode.Services.Tv; +using Microsoft.UI.Xaml.Controls; + +namespace ConsoleMode.ViewModels; + +// Settings → TV: turn the TV on and switch it to the PC's input (issue #75). +public partial class MainViewModel +{ + public ObservableCollection TvProviders { get; } = []; + public ObservableCollection TvHdmiInputs { get; } = []; + + [ObservableProperty] private ComboOption? _selectedTvProvider; + [ObservableProperty] private ComboOption? _selectedTvHdmiInput; + [ObservableProperty] private string _tvHost = ""; + [ObservableProperty] private string _tvMacAddress = ""; + [ObservableProperty] private string _tvInputCommand = ""; + [ObservableProperty] private bool _tvTurnOffOnRestore; + [ObservableProperty] private bool _isTestingTv; + + private string TvProvider => SelectedTvProvider?.Value ?? TvControlConfig.None; + + public bool IsTvEnabled => TvProvider != TvControlConfig.None; + public bool IsTvAndroid => TvProvider == TvControlConfig.AndroidTv; + public bool UsesTvHost => IsTvAndroid; + public bool UsesTvHdmiInput => IsTvAndroid; + + public string TvProviderDescription => LocalizationService.Get(TvProvider switch + { + TvControlConfig.AndroidTv => "TvAndroidDescription", + _ => "TvNoneDescription" + }); + + partial void OnSelectedTvProviderChanged(ComboOption? value) + { + OnPropertyChanged(nameof(IsTvEnabled)); + OnPropertyChanged(nameof(IsTvAndroid)); + OnPropertyChanged(nameof(UsesTvHost)); + OnPropertyChanged(nameof(UsesTvHdmiInput)); + OnPropertyChanged(nameof(TvProviderDescription)); + SaveQuietly(); + } + + partial void OnSelectedTvHdmiInputChanged(ComboOption? value) => SaveQuietly(); + partial void OnTvHostChanged(string value) => SaveQuietly(); + partial void OnTvMacAddressChanged(string value) => SaveQuietly(); + partial void OnTvInputCommandChanged(string value) => SaveQuietly(); + partial void OnTvTurnOffOnRestoreChanged(bool value) => SaveQuietly(); + + /// Part of : the names follow the interface language. + private void BuildTvOptions() + { + var provider = SelectedTvProvider?.Value ?? _loadedConfig.Tv?.Provider ?? TvControlConfig.None; + var hdmi = SelectedTvHdmiInput?.Value ?? (_loadedConfig.Tv?.HdmiInput ?? 1).ToString(); + + TvProviders.Clear(); + TvProviders.Add(new ComboOption { Text = LocalizationService.Get("TvProviderNone"), Value = TvControlConfig.None }); + TvProviders.Add(new ComboOption { Text = LocalizationService.Get("TvProviderAndroid"), Value = TvControlConfig.AndroidTv }); + SelectedTvProvider = TvProviders.FirstOrDefault(o => o.Value == provider) ?? TvProviders[0]; + + TvHdmiInputs.Clear(); + for (var i = 1; i <= 4; i++) + TvHdmiInputs.Add(new ComboOption { Text = $"HDMI {i}", Value = i.ToString() }); + SelectedTvHdmiInput = TvHdmiInputs.FirstOrDefault(o => o.Value == hdmi) ?? TvHdmiInputs[0]; + } + + private void ApplyTv(TvControlConfig? tv) + { + tv ??= new TvControlConfig(); + SelectedTvProvider = TvProviders.FirstOrDefault(o => o.Value == tv.Provider) ?? TvProviders.FirstOrDefault(); + SelectedTvHdmiInput = TvHdmiInputs.FirstOrDefault(o => o.Value == tv.HdmiInput.ToString()) ?? TvHdmiInputs.FirstOrDefault(); + TvHost = tv.Host ?? ""; + TvMacAddress = tv.MacAddress ?? ""; + TvInputCommand = tv.InputCommand ?? ""; + TvTurnOffOnRestore = tv.TurnOffOnRestore; + } + + private TvControlConfig BuildTvConfig() => new() + { + Provider = TvProvider, + Host = TvHost.Trim(), + MacAddress = TvMacAddress.Trim(), + HdmiInput = int.TryParse(SelectedTvHdmiInput?.Value, out var hdmi) ? hdmi : 1, + InputCommand = TvInputCommand.Trim(), + TurnOffOnRestore = TvTurnOffOnRestore + }; + + /// Turns the TV on and switches the input now; the first time, pairs with the TV. + [RelayCommand] + private async Task TestTvAsync() + { + if (IsTestingTv) return; + IsTestingTv = true; + SetStatus(LocalizationService.Get("TvTesting"), InfoBarSeverity.Informational); + try + { + var config = BuildTvConfig(); + await Task.Run(() => Engine.Tv.TestAsync(config, CancellationToken.None)); + SetStatus(LocalizationService.Get("TvTestSuccess"), InfoBarSeverity.Success); + } + catch (TvControlException ex) + { + AppLog.Write($"TV: teste falhou: {ex.Message}"); + SetStatus(ex.Message, InfoBarSeverity.Warning); + } + catch (Exception ex) + { + AppLog.Write($"TV: teste falhou: {ex}"); + SetStatus(LocalizationService.Get("TvTestFailure", ex.Message), InfoBarSeverity.Error); + } + finally + { + IsTestingTv = false; + } + } +} diff --git a/src/ConsoleMode/ViewModels/MainViewModel.cs b/src/ConsoleMode/ViewModels/MainViewModel.cs index cc66c31..397796a 100644 --- a/src/ConsoleMode/ViewModels/MainViewModel.cs +++ b/src/ConsoleMode/ViewModels/MainViewModel.cs @@ -193,6 +193,7 @@ private void BuildLocalizedOptions() BuildAudioOptions(audioValue); BuildUiModeOptions(); + BuildTvOptions(); FpsStatusText = LocalizationService.Get(IsFpsAvailable ? "FpsAvailable" : "FpsUnavailable"); } finally @@ -300,6 +301,7 @@ private void Apply(LoadResult data) IsPlayniteAvailable = Engine.Launch.IsPlayniteAvailable(); BuildLocalizedOptions(); SelectedLaunch = LaunchOptions.FirstOrDefault(o => o.Value == config.FullscreenMode) ?? LaunchOptions[0]; + ApplyTv(config.Tv); HdrEnable = config.HdrEnable; VrrEnable = config.VrrEnable; CheckUpdates = config.CheckUpdates; @@ -909,7 +911,8 @@ private AppConfig BuildConfig() ConsoleBackgroundImage = ConsoleBackgroundImage, CloseSteamOnRestore = CloseSteamOnRestore, UiMode = SelectedUiMode?.Value ?? _loadedConfig.UiMode, - SkippedUpdateVersion = _loadedConfig.SkippedUpdateVersion + SkippedUpdateVersion = _loadedConfig.SkippedUpdateVersion, + Tv = BuildTvConfig() }; if (Monitors.Count == 0) return config; diff --git a/src/ConsoleMode/Views/SettingsView.xaml b/src/ConsoleMode/Views/SettingsView.xaml index 225ab1a..fb411fd 100644 --- a/src/ConsoleMode/Views/SettingsView.xaml +++ b/src/ConsoleMode/Views/SettingsView.xaml @@ -106,6 +106,53 @@ OnContent="{x:Bind ViewModel.Texts.ToggleOn, Mode=OneWay}" OffContent="{x:Bind ViewModel.Texts.ToggleOff, Mode=OneWay}"/> + + + + + + + + + + + + + + + + + + + + + + + + + + +