diff --git a/CHANGELOG.en-US.md b/CHANGELOG.en-US.md
index 1a42036..9e227a0 100644
--- a/CHANGELOG.en-US.md
+++ b/CHANGELOG.en-US.md
@@ -5,6 +5,7 @@ English (US) release notes, mirroring CHANGELOG.md (Brazilian Portuguese). Befor
## [Unreleased]
### What's new
- Update notice in Portuguese: the new version's highlight shows in the app's language when there is a changelog in it (today, Portuguese); other languages still see it in English. Applies to updates published after this version. (#158)
+- **Custom app** mode: in Settings → When entering console mode, pick any `.exe` (RetroArch, another launcher…) with optional arguments. Console Mode sets up the TV, opens the app on the game screen and restores the desk by itself when it closes. For launchers that only start another program and exit, type the real program's process name in **Process to watch**. If it never shows up, the session stays until you end it (hold Start + Select). (#161)
- Diagnostics in Settings: a new card shows, read-only, the app's version and channel, Windows, the screens (resolution, HDR, which one is the game screen and which ones get hidden), the audio outputs, the connected controllers, whether Steam, Playnite, RTSS and ControlFS were found, and the end of the log. **Copy report** copies it all as text to paste into an issue, without your user name in paths and without keys. (#159)
## [1.6.2]
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1166e1e..7afcfea 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,7 @@ Notas em português do Brasil; a versão em inglês (Estados Unidos) fica em `CH
## [Unreleased]
### Novidades
- Aviso de atualização em português: o destaque da nova versão aparece no idioma do app quando há um changelog nele (hoje, português); os outros idiomas continuam vendo o destaque em inglês. Vale a partir das atualizações publicadas depois desta versão. (#158)
+- Modo **Aplicativo personalizado**: em Ajustes → Ao entrar no modo console, escolha qualquer `.exe` (RetroArch, outro launcher…) com argumentos opcionais. O Console Mode prepara a TV, abre o app na tela de jogo e restaura a mesa sozinho quando ele fecha. Para launchers que só abrem outro programa e fecham, informe em **Processo a acompanhar** o nome do processo do programa de verdade. Se ele nunca aparecer, a sessão continua até você encerrá-la (segurar Start + Select). (#161)
- Diagnóstico em Ajustes: um card novo mostra, só para leitura, a versão e o canal do app, o Windows, as telas (resolução, HDR, qual é a de jogo e quais são ocultadas), as saídas de áudio, os controles conectados, se Steam, Playnite, RTSS e ControlFS foram encontrados e o fim do log. **Copiar relatório** copia tudo em texto para colar numa issue, sem o seu nome de usuário nos caminhos e sem chaves. (#159)
## [1.6.2]
diff --git a/docs/GUIDE.de-DE.md b/docs/GUIDE.de-DE.md
index 471a273..3a55462 100644
--- a/docs/GUIDE.de-DE.md
+++ b/docs/GUIDE.de-DE.md
@@ -11,6 +11,7 @@ Details, die nicht ins [README](../README.de-DE.md) passen.
| **Steam Big Picture** | Automatische Wiederherstellung (die App bleibt im Infobereich) |
| **Playnite (Vollbild)** | Automatische Wiederherstellung (die App bleibt im Infobereich) |
| **Xbox-Modus** | Manuell: Verwende *Jetzt wiederherstellen*, das Menü im Infobereich oder öffne das Fenster erneut |
+| **Eigene App** | Automatische Wiederherstellung, wenn die App (oder der gewählte überwachte Prozess) geschlossen wird |
Du kannst auch jederzeit über den Infobereich wiederherstellen (*Konfiguration wiederherstellen* / *Fenster anzeigen*). Bei schwarzen Bildschirmen blendest du die Abdeckungen mit **ESC** aus.
diff --git a/docs/GUIDE.es-ES.md b/docs/GUIDE.es-ES.md
index 263ddb2..43cced6 100644
--- a/docs/GUIDE.es-ES.md
+++ b/docs/GUIDE.es-ES.md
@@ -11,6 +11,7 @@ Detalles que no caben en el [README](../README.es-ES.md).
| **Steam Big Picture** | Restauración automática (la aplicación sigue en la bandeja) |
| **Playnite a pantalla completa** | Restauración automática (la aplicación sigue en la bandeja) |
| **Modo Xbox** | Manual: usa *Restaurar ahora*, el menú de la bandeja o vuelve a abrir la ventana |
+| **Aplicación personalizada** | Restauración automática cuando se cierra la aplicación (o el proceso que elegiste vigilar) |
También puedes restaurar en cualquier momento desde la bandeja (*Restaurar pantallas* / *Mostrar ventana*). Con las pantallas negras, **ESC** quita las cortinas.
diff --git a/docs/GUIDE.fr-FR.md b/docs/GUIDE.fr-FR.md
index 1efdc5c..5060ac1 100644
--- a/docs/GUIDE.fr-FR.md
+++ b/docs/GUIDE.fr-FR.md
@@ -11,6 +11,7 @@ Les détails qui ne tiennent pas dans le [README](../README.fr-FR.md).
| **Steam Big Picture** | Restauration automatique (l'application reste dans la zone de notification) |
| **Playnite en plein écran** | Restauration automatique (l'application reste dans la zone de notification) |
| **Mode Xbox** | Manuelle : utilisez *Restaurer maintenant*, le menu de la zone de notification, ou rouvrez la fenêtre |
+| **Application personnalisée** | Restauration automatique quand l'application (ou le processus choisi à surveiller) se ferme |
Vous pouvez aussi restaurer à tout moment depuis la zone de notification (*Restaurer la configuration* / *Afficher la fenêtre*). Avec les écrans noirs, **Échap** retire les rideaux.
diff --git a/docs/GUIDE.md b/docs/GUIDE.md
index 3c90877..8963dca 100644
--- a/docs/GUIDE.md
+++ b/docs/GUIDE.md
@@ -11,6 +11,9 @@ English · [Português (Brasil)](GUIDE.pt-BR.md) · [Español](GUIDE.es-ES.md)
| **Steam Big Picture** | Automatic restore (app stays in the tray) |
| **Playnite fullscreen** | Automatic restore (app stays in the tray) |
| **Xbox mode** | Manual — use *Restore now*, the tray menu, or reopen the window |
+| **Custom app** | Automatic restore when the app (or the process you chose to watch) closes |
+
+**Custom app** opens any `.exe` you pick (RetroArch, another launcher…) from its own folder, with optional arguments, and moves its window to the game screen. The session ends a few seconds after that process closes. If the exe only starts another program and exits (a launcher stub), type the real program's process name in **Process to watch**. If the watched process never shows up, the session stays until you end it (hold Start + Select, the session menu or the tray).
You can also restore anytime from the tray (*Restore setup* / *Show window*). With black overlays, **ESC** dismisses the curtains.
diff --git a/docs/GUIDE.pt-BR.md b/docs/GUIDE.pt-BR.md
index be757ae..29f655f 100644
--- a/docs/GUIDE.pt-BR.md
+++ b/docs/GUIDE.pt-BR.md
@@ -11,6 +11,9 @@ Detalhes que não cabem no [README](../README.pt-BR.md).
| **Steam Big Picture** | Restauração automática (app na bandeja) |
| **Playnite tela cheia** | Restauração automática (app na bandeja) |
| **Modo Xbox** | Manual — *Restaurar agora*, menu da bandeja ou reabrir a janela |
+| **Aplicativo personalizado** | Restauração automática quando o app (ou o processo escolhido para acompanhar) fecha |
+
+**Aplicativo personalizado** abre qualquer `.exe` que você escolher (RetroArch, outro launcher…) a partir da própria pasta, com argumentos opcionais, e move a janela dele para a tela de jogo. A sessão termina alguns segundos depois que esse processo fecha. Se o exe só abre outro programa e fecha (um launcher intermediário), digite o nome do processo do programa de verdade em **Processo a acompanhar**. Se o processo acompanhado nunca aparecer, a sessão continua até você encerrá-la (segurar Start + Select, o menu da sessão ou a bandeja).
Também dá para restaurar a qualquer momento pela bandeja (*Restaurar setup* / *Mostrar janela*). Com cortinas pretas, **ESC** remove o overlay.
diff --git a/docs/GUIDE.ru-RU.md b/docs/GUIDE.ru-RU.md
index c85893b..9457df8 100644
--- a/docs/GUIDE.ru-RU.md
+++ b/docs/GUIDE.ru-RU.md
@@ -11,6 +11,7 @@
| **Steam Big Picture** | Автоматическое восстановление (приложение остаётся в области уведомлений) |
| **Playnite в полноэкранном режиме** | Автоматическое восстановление (приложение остаётся в области уведомлений) |
| **Режим Xbox** | Вручную: нажмите *«Восстановить сейчас»*, используйте меню в области уведомлений или снова откройте окно |
+| **Своё приложение** | Автоматическое восстановление, когда приложение (или выбранный отслеживаемый процесс) закрывается |
Восстановить конфигурацию можно в любой момент из области уведомлений (*«Восстановить конфигурацию»* / *«Показать окно»*). Если экраны закрыты чёрным экраном, **ESC** убирает затемнение.
diff --git a/docs/TESTING.md b/docs/TESTING.md
index 9165b7f..0c523b5 100644
--- a/docs/TESTING.md
+++ b/docs/TESTING.md
@@ -378,6 +378,16 @@ Pré-condições: 1.6 instalada por cima da 1.5, com a configuração da 1.5 (te
- [ ] "Cortinas pretas": as telas da mesa ficam pretas e voltam ao restaurar. Resultado: ______
- [ ] "Desligar por DDC/CI" (monitor com DDC/CI ligado no menu dele): as telas da mesa apagam e voltam a acender ao restaurar. Resultado: ______
+### Aplicativo personalizado
+
+- [ ] RetroArch: em Ajustes → Ao entrar no modo console, escolher "Aplicativo personalizado", Procurar… e selecionar `retroarch.exe` (argumentos vazios). Iniciar: as telas trocam como nos outros modos, o RetroArch abre na TV e na frente. O resumo e a tela da sessão mostram "RetroArch". Resultado: ______
+- [ ] Argumentos: com `--fullscreen` (ou o argumento equivalente do app), o app abre em tela cheia; a pasta de trabalho é a do exe (o RetroArch acha os cores e a configuração). Resultado: ______
+- [ ] Fechar o app escolhido restaura a mesa sozinho em poucos segundos (log: `Loop: processo … fechou`). Resultado: ______
+- [ ] Launcher intermediário que abre outro processo e fecha (ex.: um atalho/launcher de jogo): com o nome do processo real em "Processo a acompanhar", a sessão continua depois que o launcher fecha e só restaura quando o processo real fecha. Resultado: ______
+- [ ] "Processo a acompanhar" com um nome que nunca aparece: depois de ~90 s o log mostra `não apareceu`, a mesa **não** volta sozinha, e segurar Start + Select (ou o menu da bandeja) restaura. Resultado: ______
+- [ ] Caminho inválido (exe apagado ou renomeado depois de escolhido) ou nenhum app escolhido: Iniciar mostra o aviso e nada muda nas telas. Resultado: ______
+- [ ] Na interface Console, a linha "Aplicativo a abrir" aparece só com esse modo escolhido e abre o seletor de arquivos. Resultado: ______
+
### Janelas e recuperação
- [ ] Big Picture ou Playnite abrindo na tela errada é movido para a TV. Resultado: ______
diff --git a/src/ConsoleMode/Models/Models.cs b/src/ConsoleMode/Models/Models.cs
index 69808fc..d8e3a6a 100644
--- a/src/ConsoleMode/Models/Models.cs
+++ b/src/ConsoleMode/Models/Models.cs
@@ -16,6 +16,12 @@ public sealed class AppConfig
/// Playnite folder or exe chosen by the user (portable installs); empty = auto-detect.
public string PlaynitePath { get; set; } = "";
+
+ /// "customApp" mode: the exe to launch, its optional arguments, and an optional process to
+ /// watch instead of it (for launcher stubs that hand off to another process).
+ public string CustomAppPath { get; set; } = "";
+ public string CustomAppArguments { get; set; } = "";
+ public string CustomAppWatchProcess { get; set; } = "";
public string AudioDeviceId { get; set; } = "";
public string AudioDeviceName { get; set; } = "";
public bool AudioAutoSwitch { get; set; }
@@ -340,6 +346,10 @@ public sealed class ConsoleRuntimeState
public bool AudioPendingTarget { get; set; }
public nint CachedBigPictureHandle { get; set; }
public DateTime? PlayniteWindowMissingSince { get; set; }
+ /// "customApp": the process name that keeps the session going, and when it went missing.
+ public string CustomAppWatchName { get; set; } = "";
+ public DateTime? CustomAppGoneSince { get; set; }
+ public bool CustomAppTimeoutLogged { get; set; }
public nint CachedXboxHandle { get; set; }
public bool BigPictureWatchActive { get; set; }
public bool AudioWatchComplete { get; set; }
diff --git a/src/ConsoleMode/Resources/Strings.de-DE.json b/src/ConsoleMode/Resources/Strings.de-DE.json
index 0adf161..801949e 100644
--- a/src/ConsoleMode/Resources/Strings.de-DE.json
+++ b/src/ConsoleMode/Resources/Strings.de-DE.json
@@ -234,6 +234,17 @@
"PlaynitePathInvalid": "Playnite.FullscreenApp.exe wurde unter {0} nicht gefunden.",
"PlaynitePathPickError": "Dateiauswahl konnte nicht geöffnet werden: {0}",
"LaunchXbox": "Xbox-Modus (experimentell)",
+ "LaunchCustomApp": "Eigene App",
+ "LaunchCustomAppDescription": "Öffnet das Programm deiner Wahl (einen Emulator, einen anderen Launcher…). Deine Bildschirme werden automatisch wiederhergestellt, wenn du es schließt.",
+ "CustomAppPathCard": "Zu öffnende App",
+ "CustomAppBrowse": "Durchsuchen…",
+ "CustomAppArgumentsCard": "Argumente",
+ "CustomAppArgumentsDescription": "Optional. Werden der App beim Start übergeben, zum Beispiel um sie im Vollbild zu öffnen.",
+ "CustomAppWatchCard": "Zu überwachender Prozess",
+ "CustomAppWatchDescription": "Optional. Wenn die App nur ein anderes Programm startet und sich schließt, gib den Prozessnamen dieses Programms ein. Deine Bildschirme werden wiederhergestellt, wenn es geschlossen wird.",
+ "CustomAppMissing": "Wähle die App, die geöffnet werden soll (eine .exe-Datei).",
+ "CustomAppNotExe": "{0} ist keine .exe-Datei. Wähle die App, die geöffnet werden soll.",
+ "CustomAppNotFound": "{0} wurde nicht gefunden. Wähle die App erneut.",
"FpsOverlayCard": "FPS-Anzeige",
"FpsOverlayExternal": "Afterburner (unverändert lassen)",
"FpsOverlayOff": "Aus (alles ausblenden)",
@@ -337,6 +348,7 @@
"ActivePlaynite": "Schließe Playnite, um deine Bildschirme automatisch wiederherzustellen.",
"ActiveXbox": "Im Xbox-Modus erfolgt die Wiederherstellung manuell: Verwende „Jetzt wiederherstellen“ oder das Menü im Infobereich.",
"ActiveSteam": "Schließe Big Picture, um deine Bildschirme automatisch wiederherzustellen.",
+ "ActiveCustomApp": "Schließe {0}, um deine Bildschirme automatisch wiederherzustellen.",
"InstallKindInstalled": "Version {0} · installiert",
"InstallKindPortable": "Version {0} · portabel",
"Download": "Herunterladen",
diff --git a/src/ConsoleMode/Resources/Strings.en-US.json b/src/ConsoleMode/Resources/Strings.en-US.json
index 158941e..8a15fc7 100644
--- a/src/ConsoleMode/Resources/Strings.en-US.json
+++ b/src/ConsoleMode/Resources/Strings.en-US.json
@@ -234,6 +234,17 @@
"PlaynitePathInvalid": "Could not find Playnite.FullscreenApp.exe at {0}.",
"PlaynitePathPickError": "Could not open the file picker: {0}",
"LaunchXbox": "Xbox mode (experimental)",
+ "LaunchCustomApp": "Custom app",
+ "LaunchCustomAppDescription": "Opens the program you choose (an emulator, another launcher…). Your displays are restored automatically when you close it.",
+ "CustomAppPathCard": "App to open",
+ "CustomAppBrowse": "Browse…",
+ "CustomAppArgumentsCard": "Arguments",
+ "CustomAppArgumentsDescription": "Optional. Passed to the app when it starts, for example to open it in fullscreen.",
+ "CustomAppWatchCard": "Process to watch",
+ "CustomAppWatchDescription": "Optional. When the app only starts another program and closes, type that program's process name. Your displays are restored when it closes.",
+ "CustomAppMissing": "Choose the app to open (an .exe file).",
+ "CustomAppNotExe": "{0} is not an .exe file. Choose the app to open.",
+ "CustomAppNotFound": "Could not find {0}. Choose the app again.",
"FpsOverlayCard": "FPS counter",
"FpsOverlayExternal": "Afterburner (leave as is)",
"FpsOverlayOff": "Off (hide everything)",
@@ -337,6 +348,7 @@
"ActivePlaynite": "Close Playnite to restore your displays automatically.",
"ActiveXbox": "In Xbox mode, restoration is manual: use Restore now or the system tray menu.",
"ActiveSteam": "Close Big Picture to restore your displays automatically.",
+ "ActiveCustomApp": "Close {0} to restore your displays automatically.",
"InstallKindInstalled": "Version {0} · installed",
"InstallKindPortable": "Version {0} · portable",
"Download": "Download",
diff --git a/src/ConsoleMode/Resources/Strings.es-ES.json b/src/ConsoleMode/Resources/Strings.es-ES.json
index 7e724c9..9bf50d2 100644
--- a/src/ConsoleMode/Resources/Strings.es-ES.json
+++ b/src/ConsoleMode/Resources/Strings.es-ES.json
@@ -234,6 +234,17 @@
"PlaynitePathInvalid": "No se encontró Playnite.FullscreenApp.exe en {0}.",
"PlaynitePathPickError": "No se pudo abrir el selector de archivos: {0}",
"LaunchXbox": "Modo Xbox (experimental)",
+ "LaunchCustomApp": "Aplicación personalizada",
+ "LaunchCustomAppDescription": "Abre el programa que elijas (un emulador, otro launcher…). Al cerrarlo, tus pantallas vuelven solas.",
+ "CustomAppPathCard": "Aplicación que abrir",
+ "CustomAppBrowse": "Examinar…",
+ "CustomAppArgumentsCard": "Argumentos",
+ "CustomAppArgumentsDescription": "Opcional. Se pasan a la aplicación al abrirla, por ejemplo para abrirla en pantalla completa.",
+ "CustomAppWatchCard": "Proceso que vigilar",
+ "CustomAppWatchDescription": "Opcional. Si la aplicación solo abre otro programa y se cierra, escribe el nombre del proceso de ese programa. Tus pantallas vuelven cuando se cierre.",
+ "CustomAppMissing": "Elige la aplicación que abrir (un archivo .exe).",
+ "CustomAppNotExe": "{0} no es un archivo .exe. Elige la aplicación que abrir.",
+ "CustomAppNotFound": "No se encontró {0}. Vuelve a elegir la aplicación.",
"FpsOverlayCard": "Contador de FPS",
"FpsOverlayExternal": "Afterburner (no tocar)",
"FpsOverlayOff": "Desactivado (oculta todo)",
@@ -337,6 +348,7 @@
"ActivePlaynite": "Cierra Playnite para que tus pantallas vuelvan solas.",
"ActiveXbox": "En modo Xbox la restauración es manual: usa Restaurar ahora o el menú de la bandeja.",
"ActiveSteam": "Cierra Big Picture para que tus pantallas vuelvan solas.",
+ "ActiveCustomApp": "Cierra {0} para que tus pantallas vuelvan solas.",
"InstallKindInstalled": "Versión {0} · instalada",
"InstallKindPortable": "Versión {0} · portátil",
"Download": "Descargar",
diff --git a/src/ConsoleMode/Resources/Strings.fr-FR.json b/src/ConsoleMode/Resources/Strings.fr-FR.json
index dcf72f1..16e0143 100644
--- a/src/ConsoleMode/Resources/Strings.fr-FR.json
+++ b/src/ConsoleMode/Resources/Strings.fr-FR.json
@@ -234,6 +234,17 @@
"PlaynitePathInvalid": "Playnite.FullscreenApp.exe introuvable dans {0}.",
"PlaynitePathPickError": "Impossible d'ouvrir le sélecteur de fichiers : {0}",
"LaunchXbox": "Mode Xbox (expérimental)",
+ "LaunchCustomApp": "Application personnalisée",
+ "LaunchCustomAppDescription": "Ouvre le programme de votre choix (un émulateur, un autre launcher…). Vos écrans sont restaurés automatiquement quand vous le fermez.",
+ "CustomAppPathCard": "Application à ouvrir",
+ "CustomAppBrowse": "Parcourir…",
+ "CustomAppArgumentsCard": "Arguments de lancement",
+ "CustomAppArgumentsDescription": "Facultatif. Transmis à l'application à son lancement, par exemple pour l'ouvrir en plein écran.",
+ "CustomAppWatchCard": "Processus à surveiller",
+ "CustomAppWatchDescription": "Facultatif. Si l'application ne fait que lancer un autre programme puis se ferme, saisissez le nom du processus de ce programme. Vos écrans sont restaurés quand il se ferme.",
+ "CustomAppMissing": "Choisissez l'application à ouvrir (un fichier .exe).",
+ "CustomAppNotExe": "{0} n'est pas un fichier .exe. Choisissez l'application à ouvrir.",
+ "CustomAppNotFound": "{0} est introuvable. Choisissez à nouveau l'application.",
"FpsOverlayCard": "Compteur de FPS",
"FpsOverlayExternal": "Afterburner (ne pas toucher)",
"FpsOverlayOff": "Désactivé (tout masquer)",
@@ -337,6 +348,7 @@
"ActivePlaynite": "Fermez Playnite pour restaurer automatiquement vos écrans.",
"ActiveXbox": "En mode Xbox, la restauration est manuelle : utilisez Restaurer maintenant ou le menu de la zone de notification.",
"ActiveSteam": "Fermez Big Picture pour restaurer automatiquement vos écrans.",
+ "ActiveCustomApp": "Fermez {0} pour restaurer automatiquement vos écrans.",
"InstallKindInstalled": "Version {0} · installée",
"InstallKindPortable": "Version {0} · portable",
"Download": "Télécharger",
diff --git a/src/ConsoleMode/Resources/Strings.pt-BR.json b/src/ConsoleMode/Resources/Strings.pt-BR.json
index 0f5aaeb..ecd9958 100644
--- a/src/ConsoleMode/Resources/Strings.pt-BR.json
+++ b/src/ConsoleMode/Resources/Strings.pt-BR.json
@@ -234,6 +234,17 @@
"PlaynitePathInvalid": "Não achei o Playnite.FullscreenApp.exe em {0}.",
"PlaynitePathPickError": "Não foi possível abrir o seletor de arquivos: {0}",
"LaunchXbox": "Modo Xbox (experimental)",
+ "LaunchCustomApp": "Aplicativo personalizado",
+ "LaunchCustomAppDescription": "Abre o programa que você escolher (um emulador, outro launcher…). Ao fechar, suas telas voltam sozinhas.",
+ "CustomAppPathCard": "Aplicativo a abrir",
+ "CustomAppBrowse": "Procurar…",
+ "CustomAppArgumentsCard": "Argumentos",
+ "CustomAppArgumentsDescription": "Opcional. Passados ao aplicativo quando ele abre, por exemplo para abrir em tela cheia.",
+ "CustomAppWatchCard": "Processo a acompanhar",
+ "CustomAppWatchDescription": "Opcional. Se o aplicativo só abre outro programa e fecha, digite o nome do processo desse programa. Suas telas voltam quando ele fechar.",
+ "CustomAppMissing": "Escolha o aplicativo a abrir (um arquivo .exe).",
+ "CustomAppNotExe": "{0} não é um arquivo .exe. Escolha o aplicativo a abrir.",
+ "CustomAppNotFound": "{0} não foi encontrado. Escolha o aplicativo de novo.",
"FpsOverlayCard": "Contador de FPS",
"FpsOverlayExternal": "Afterburner (não mexer)",
"FpsOverlayOff": "Desligado (esconde tudo)",
@@ -337,6 +348,7 @@
"ActivePlaynite": "Feche o Playnite e suas telas voltam sozinhas.",
"ActiveXbox": "No Modo Xbox a volta é manual: use Restaurar agora ou o menu da bandeja.",
"ActiveSteam": "Feche o Big Picture e suas telas voltam sozinhas.",
+ "ActiveCustomApp": "Feche o {0} e suas telas voltam sozinhas.",
"InstallKindInstalled": "Versão {0} · instalada",
"InstallKindPortable": "Versão {0} · portátil",
"Download": "Baixar",
diff --git a/src/ConsoleMode/Resources/Strings.ru-RU.json b/src/ConsoleMode/Resources/Strings.ru-RU.json
index 528de42..541cee9 100644
--- a/src/ConsoleMode/Resources/Strings.ru-RU.json
+++ b/src/ConsoleMode/Resources/Strings.ru-RU.json
@@ -234,6 +234,17 @@
"PlaynitePathInvalid": "Не удалось найти Playnite.FullscreenApp.exe в {0}.",
"PlaynitePathPickError": "Не удалось открыть окно выбора файла: {0}",
"LaunchXbox": "Режим Xbox (экспериментально)",
+ "LaunchCustomApp": "Своё приложение",
+ "LaunchCustomAppDescription": "Открывает выбранную вами программу (эмулятор, другой лаунчер…). Экраны восстанавливаются автоматически, когда вы её закроете.",
+ "CustomAppPathCard": "Открываемое приложение",
+ "CustomAppBrowse": "Обзор…",
+ "CustomAppArgumentsCard": "Аргументы",
+ "CustomAppArgumentsDescription": "Необязательно. Передаются приложению при запуске, например чтобы открыть его во весь экран.",
+ "CustomAppWatchCard": "Отслеживаемый процесс",
+ "CustomAppWatchDescription": "Необязательно. Если приложение только запускает другую программу и закрывается, введите имя процесса этой программы. Экраны восстановятся, когда она закроется.",
+ "CustomAppMissing": "Выберите приложение для запуска (файл .exe).",
+ "CustomAppNotExe": "{0} — не файл .exe. Выберите приложение для запуска.",
+ "CustomAppNotFound": "Не удалось найти {0}. Выберите приложение снова.",
"FpsOverlayCard": "Счётчик FPS",
"FpsOverlayExternal": "Afterburner (не менять)",
"FpsOverlayOff": "Выкл. (скрыть всё)",
@@ -337,6 +348,7 @@
"ActivePlaynite": "Закройте Playnite, чтобы экраны восстановились автоматически.",
"ActiveXbox": "В режиме Xbox восстановление выполняется вручную: нажмите «Восстановить сейчас» или используйте меню в области уведомлений.",
"ActiveSteam": "Закройте Big Picture, чтобы экраны восстановились автоматически.",
+ "ActiveCustomApp": "Закройте {0}, чтобы экраны восстановились автоматически.",
"InstallKindInstalled": "Версия {0} · установленная",
"InstallKindPortable": "Версия {0} · портативная",
"Download": "Скачать",
diff --git a/src/ConsoleMode/Services/ConsoleEngine.cs b/src/ConsoleMode/Services/ConsoleEngine.cs
index f90e0fd..7a1a0f6 100644
--- a/src/ConsoleMode/Services/ConsoleEngine.cs
+++ b/src/ConsoleMode/Services/ConsoleEngine.cs
@@ -36,6 +36,9 @@ private void OnUi(Action action)
public void Start(AppConfig config, MonitorInfo? focusInfo, Func? confirmScreen = null)
{
if (State.IsActive) throw new InvalidOperationException(LocalizationService.Get("AlreadyConsoleActive"));
+ // Before anything changes on screen: a custom app that can't be launched blocks the start.
+ if (config.FullscreenMode == CustomApp.Mode && CustomApp.ProblemText(config.CustomAppPath) is { } problem)
+ throw new InvalidOperationException(problem);
config = ResolveMonitorNames(config);
AppLog.Write($"Start: foco={config.FocusMonitor}, esconder={string.Join('+', config.HideMonitors)}, estratégia={config.HideStrategy}, modo={config.FullscreenMode}");
@@ -59,6 +62,9 @@ public void Start(AppConfig config, MonitorInfo? focusInfo, Func
+ /// "customApp": the session lasts while the watched process runs. It ends once that process has
+ /// appeared and stayed gone for a grace period; one that never shows up keeps the session until ended by hand.
+ ///
+ private bool IsCustomAppExit()
+ {
+ var name = State.CustomAppWatchName;
+ var now = DateTime.Now;
+ var running = LaunchService.IsProcessRunning(name);
+ if (running)
+ {
+ State.CustomAppGoneSince = null;
+ if (!State.HasAppeared) AppLog.Write($"Loop: processo {name} apareceu");
+ State.HasAppeared = true;
+ PlaceCustomAppWindow(name);
+ }
+ else if (State.HasAppeared)
+ {
+ State.CustomAppGoneSince ??= now;
+ }
+ else if (!State.CustomAppTimeoutLogged && CustomApp.HasTimedOut(State.HasAppeared, State.LaunchTime, now))
+ {
+ State.CustomAppTimeoutLogged = true;
+ AppLog.Write($"Loop: processo {name} não apareceu em {CustomApp.AppearTimeout.TotalSeconds:0} s; a sessão continua até ser encerrada manualmente");
+ }
+
+ var exit = CustomApp.IsExit(State.HasAppeared, running, State.CustomAppGoneSince, now);
+ if (exit) AppLog.Write($"Loop: processo {name} fechou");
+ return exit;
+ }
+
+ /// Once its main window shows: moves it to the game screen (a few tries) and brings it to the front.
+ private void PlaceCustomAppWindow(string name)
+ {
+ if (State.SteamMoved || string.IsNullOrWhiteSpace(State.FocusMonitor)) return;
+ var handles = LaunchService.GetProcessWindows(name);
+ if (handles.Length == 0) return;
+
+ Monitors.UpdateFocusRect(State.FocusMonitor, State, true);
+ var rect = Monitors.GetMonitorRect(State.FocusMonitor, State);
+ var handle = handles[0];
+ if (rect is not null && !NativeWindows.IsWindowCenterOnRect(handle, rect.X, rect.Y, rect.Width, rect.Height))
+ {
+ if (State.MoveCount < 8)
+ {
+ Monitors.MoveProcessWindows(State.FocusMonitor, rect, name);
+ State.MoveCount++;
+ return;
+ }
+ AppLog.Write($"Loop: janela de {name} não chegou na tela de jogo; deixando onde está");
+ }
+
+ State.SteamMoved = true;
+ if (!WindowSwitcher.Activate(handle)) AppLog.Write($"Loop: janela de {name} não veio para frente");
+ }
+
private bool AudioWatchNeeded()
{
if (State.AudioWatchComplete) return false;
diff --git a/src/ConsoleMode/Services/CustomApp.cs b/src/ConsoleMode/Services/CustomApp.cs
new file mode 100644
index 0000000..2d810ef
--- /dev/null
+++ b/src/ConsoleMode/Services/CustomApp.cs
@@ -0,0 +1,72 @@
+namespace ConsoleMode.Services;
+
+///
+/// The "custom app" launch mode: any .exe as the console launcher (issue #111). Validation, names and
+/// the decision of when the session is over. Pure, so it's tested.
+///
+public static class CustomApp
+{
+ public const string Mode = "customApp";
+
+ /// How long the watched process may be gone before the session ends (covers restarts and hand-offs).
+ public static readonly TimeSpan ExitGrace = TimeSpan.FromSeconds(8);
+
+ /// How long to wait for the watched process to show up before giving up on watching it.
+ public static readonly TimeSpan AppearTimeout = TimeSpan.FromSeconds(90);
+
+ public enum Problem { None, Missing, NotExe, NotFound }
+
+ public static string CleanPath(string? path) => (path ?? "").Trim().Trim('"').Trim();
+
+ public static Problem Validate(string? path)
+ {
+ var clean = CleanPath(path);
+ if (clean.Length == 0) return Problem.Missing;
+ if (!clean.EndsWith(".exe", StringComparison.OrdinalIgnoreCase)) return Problem.NotExe;
+ return File.Exists(clean) ? Problem.None : Problem.NotFound;
+ }
+
+ /// The message that blocks starting, or null when the exe can be launched.
+ public static string? ProblemText(string? path) => Validate(path) switch
+ {
+ Problem.Missing => LocalizationService.Get("CustomAppMissing"),
+ Problem.NotExe => LocalizationService.Get("CustomAppNotExe", CleanPath(path)),
+ Problem.NotFound => LocalizationService.Get("CustomAppNotFound", CleanPath(path)),
+ _ => null
+ };
+
+ /// A process name as Process.GetProcessesByName wants it: no folder, no ".exe".
+ public static string NormalizeProcessName(string? name)
+ {
+ var clean = CleanPath(name);
+ var slash = clean.LastIndexOfAny(['\\', '/']);
+ if (slash >= 0) clean = clean[(slash + 1)..];
+ if (clean.EndsWith(".exe", StringComparison.OrdinalIgnoreCase)) clean = clean[..^4];
+ return clean.Trim();
+ }
+
+ /// The process that keeps the session going: the one the user named, else the launched exe.
+ public static string WatchedProcess(string? exePath, string? watchProcess)
+ {
+ var watch = NormalizeProcessName(watchProcess);
+ return watch.Length > 0 ? watch : NormalizeProcessName(exePath);
+ }
+
+ /// The name shown for the mode: the exe's description, else its file name.
+ public static string DisplayName(string? exePath, string? fileDescription)
+ {
+ var description = fileDescription?.Trim() ?? "";
+ return description.Length > 0 ? description : NormalizeProcessName(exePath);
+ }
+
+ /// When the watched process was last seen missing after it appeared, or null while it runs.
+ public static bool IsExit(bool hasAppeared, bool running, DateTime? goneSince, DateTime now)
+ {
+ if (!hasAppeared || running || goneSince is null) return false;
+ return now - goneSince.Value >= ExitGrace;
+ }
+
+ /// The process never showed up in time: stop waiting (the session stays until ended by hand).
+ public static bool HasTimedOut(bool hasAppeared, DateTime? launchTime, DateTime now) =>
+ !hasAppeared && launchTime is not null && now - launchTime.Value >= AppearTimeout;
+}
diff --git a/src/ConsoleMode/Services/LaunchService.cs b/src/ConsoleMode/Services/LaunchService.cs
index 08c2213..0915bd7 100644
--- a/src/ConsoleMode/Services/LaunchService.cs
+++ b/src/ConsoleMode/Services/LaunchService.cs
@@ -22,6 +22,54 @@ public void StartPlaynite()
ProcessRunner.StartDetached("playnite://playnite/start");
}
+ /// "customApp": starts the exe from its own folder (emulators look for their files there).
+ public void StartCustomApp(string exePath, string? arguments)
+ {
+ var exe = CustomApp.CleanPath(exePath);
+ Process.Start(new ProcessStartInfo
+ {
+ FileName = exe,
+ Arguments = arguments?.Trim() ?? "",
+ WorkingDirectory = Path.GetDirectoryName(exe) ?? "",
+ UseShellExecute = true
+ })?.Dispose();
+ }
+
+ /// The exe's FileDescription ("RetroArch"), else its file name.
+ public static string GetCustomAppName(string exePath)
+ {
+ string? description = null;
+ try
+ {
+ var exe = CustomApp.CleanPath(exePath);
+ if (File.Exists(exe)) description = FileVersionInfo.GetVersionInfo(exe).FileDescription;
+ }
+ catch { /* no version info: fall back to the file name */ }
+ return CustomApp.DisplayName(exePath, description);
+ }
+
+ public static bool IsProcessRunning(string processName)
+ {
+ if (string.IsNullOrWhiteSpace(processName)) return false;
+ var processes = Process.GetProcessesByName(processName);
+ foreach (var p in processes) p.Dispose();
+ return processes.Length > 0;
+ }
+
+ public static nint[] GetProcessWindows(string processName)
+ {
+ if (string.IsNullOrWhiteSpace(processName)) return [];
+ var handles = new List();
+ foreach (var p in Process.GetProcessesByName(processName))
+ {
+ using (p)
+ {
+ if (p.MainWindowHandle != 0) handles.Add(p.MainWindowHandle);
+ }
+ }
+ return [.. handles];
+ }
+
public bool IsPlayniteAvailable() => GetPlaynitePath() is not null;
/// User-chosen Playnite folder or exe (portable installs); tried before auto-detection.
diff --git a/src/ConsoleMode/Services/LocalizationService.cs b/src/ConsoleMode/Services/LocalizationService.cs
index 7075a19..67cb87e 100644
--- a/src/ConsoleMode/Services/LocalizationService.cs
+++ b/src/ConsoleMode/Services/LocalizationService.cs
@@ -149,6 +149,12 @@ internal void NotifyAll()
public string PlaynitePathCard => LocalizationService.Get(nameof(PlaynitePathCard));
public string PlaynitePathChoose => LocalizationService.Get(nameof(PlaynitePathChoose));
public string PlaynitePathClear => LocalizationService.Get(nameof(PlaynitePathClear));
+ public string CustomAppPathCard => LocalizationService.Get(nameof(CustomAppPathCard));
+ public string CustomAppBrowse => LocalizationService.Get(nameof(CustomAppBrowse));
+ public string CustomAppArgumentsCard => LocalizationService.Get(nameof(CustomAppArgumentsCard));
+ public string CustomAppArgumentsDescription => LocalizationService.Get(nameof(CustomAppArgumentsDescription));
+ public string CustomAppWatchCard => LocalizationService.Get(nameof(CustomAppWatchCard));
+ public string CustomAppWatchDescription => LocalizationService.Get(nameof(CustomAppWatchDescription));
public string AudioOutputCard => LocalizationService.Get(nameof(AudioOutputCard));
public string OptionalExtrasSection => LocalizationService.Get(nameof(OptionalExtrasSection));
public string HdrCard => LocalizationService.Get(nameof(HdrCard));
diff --git a/src/ConsoleMode/ViewModels/MainViewModel.CustomApp.cs b/src/ConsoleMode/ViewModels/MainViewModel.CustomApp.cs
new file mode 100644
index 0000000..6e3a1f3
--- /dev/null
+++ b/src/ConsoleMode/ViewModels/MainViewModel.CustomApp.cs
@@ -0,0 +1,59 @@
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using ConsoleMode.Services;
+using Microsoft.UI.Xaml.Controls;
+using Windows.Storage.Pickers;
+
+namespace ConsoleMode.ViewModels;
+
+// "Custom app" launch mode: any .exe as the console launcher (issue #111).
+public partial class MainViewModel
+{
+ [ObservableProperty] private string _customAppPath = "";
+ [ObservableProperty] private string _customAppArguments = "";
+ [ObservableProperty] private string _customAppWatchProcess = "";
+
+ public bool IsCustomApp => SelectedLaunch?.Value == CustomApp.Mode;
+
+ /// The exe's description or file name, shown in summaries.
+ public string CustomAppName => CustomApp.Validate(CustomAppPath) == CustomApp.Problem.None
+ ? LaunchService.GetCustomAppName(CustomAppPath)
+ : LocalizationService.Get("LaunchCustomApp");
+
+ public string CustomAppPathText => CustomApp.ProblemText(CustomAppPath) ?? CustomApp.CleanPath(CustomAppPath);
+
+ /// The selected launcher as the summaries show it: a custom app by its own name.
+ private string LaunchSummaryText => IsCustomApp
+ ? CustomAppName
+ : SelectedLaunch?.Text ?? LocalizationService.Get("LaunchSteam");
+
+ partial void OnCustomAppPathChanged(string value)
+ {
+ OnPropertyChanged(nameof(CustomAppName));
+ OnPropertyChanged(nameof(CustomAppPathText));
+ SettingChanged();
+ }
+
+ partial void OnCustomAppArgumentsChanged(string value) => SaveQuietly();
+ partial void OnCustomAppWatchProcessChanged(string value) => SaveQuietly();
+
+ [RelayCommand]
+ private async Task PickCustomAppPathAsync()
+ {
+ try
+ {
+ var picker = new FileOpenPicker { SuggestedStartLocation = PickerLocationId.ComputerFolder };
+ picker.FileTypeFilter.Add(".exe");
+ if (App.MainWindowInstance is { } window)
+ WinRT.Interop.InitializeWithWindow.Initialize(picker, WinRT.Interop.WindowNative.GetWindowHandle(window));
+ var file = await picker.PickSingleFileAsync();
+ if (file is null) return;
+ CustomAppPath = file.Path;
+ }
+ catch (Exception ex)
+ {
+ AppLog.Write($"Aplicativo personalizado: {ex}");
+ SetStatus(LocalizationService.Get("PlaynitePathPickError", ex.Message), InfoBarSeverity.Error);
+ }
+ }
+}
diff --git a/src/ConsoleMode/ViewModels/MainViewModel.cs b/src/ConsoleMode/ViewModels/MainViewModel.cs
index 4e2f267..50b2e74 100644
--- a/src/ConsoleMode/ViewModels/MainViewModel.cs
+++ b/src/ConsoleMode/ViewModels/MainViewModel.cs
@@ -83,6 +83,7 @@ public partial class MainViewModel : ObservableObject
{
"playnite" => LocalizationService.Get("LaunchPlayniteDescription"),
"xboxMode" => LocalizationService.Get("LaunchXboxDescription"),
+ CustomApp.Mode => LocalizationService.Get("LaunchCustomAppDescription"),
_ => LocalizationService.Get("LaunchSteamDescription")
};
public string FocusModeDescription => FocusRow is null
@@ -142,6 +143,8 @@ private void OnLanguageChanged(object? sender, EventArgs e)
{
OnPropertyChanged(nameof(StartButtonText));
OnPropertyChanged(nameof(LaunchDescription));
+ OnPropertyChanged(nameof(CustomAppName));
+ OnPropertyChanged(nameof(CustomAppPathText));
OnPropertyChanged(nameof(FocusModeDescription));
OnPropertyChanged(nameof(InstallKindText));
OnPropertyChanged(nameof(UpdateActionText));
@@ -176,6 +179,7 @@ private void BuildLocalizedOptions()
if (IsPlayniteAvailable)
LaunchOptions.Add(new ComboOption { Text = LocalizationService.Get("LaunchPlaynite"), Value = "playnite" });
LaunchOptions.Add(new ComboOption { Text = LocalizationService.Get("LaunchXbox"), Value = "xboxMode" });
+ LaunchOptions.Add(new ComboOption { Text = LocalizationService.Get("LaunchCustomApp"), Value = CustomApp.Mode });
SelectedLaunch = LaunchOptions.FirstOrDefault(x => x.Value == launchValue) ?? LaunchOptions[0];
FpsOptions.Clear();
@@ -300,6 +304,9 @@ private void Apply(LoadResult data)
SelectedHideStrategy = HideStrategies.FirstOrDefault(s => s.Value == config.HideStrategy) ?? HideStrategies[0];
PlaynitePath = config.PlaynitePath ?? "";
+ CustomAppPath = config.CustomAppPath ?? "";
+ CustomAppArguments = config.CustomAppArguments ?? "";
+ CustomAppWatchProcess = config.CustomAppWatchProcess ?? "";
IsPlayniteAvailable = Engine.Launch.IsPlayniteAvailable();
BuildLocalizedOptions();
SelectedLaunch = LaunchOptions.FirstOrDefault(o => o.Value == config.FullscreenMode) ?? LaunchOptions[0];
@@ -539,6 +546,7 @@ partial void OnSelectedLanguageChanged(ComboOption? value)
partial void OnSelectedLaunchChanged(ComboOption? value)
{
OnPropertyChanged(nameof(LaunchDescription));
+ OnPropertyChanged(nameof(IsCustomApp));
SettingChanged();
}
@@ -622,6 +630,11 @@ public async Task StartAsync()
SetStatus(LocalizationService.Get("ChooseGameScreen"), InfoBarSeverity.Warning);
return;
}
+ if (config.FullscreenMode == CustomApp.Mode && CustomApp.ProblemText(config.CustomAppPath) is { } problem)
+ {
+ SetStatus(problem, InfoBarSeverity.Warning);
+ return;
+ }
TrySave(config);
_busy = true;
@@ -907,6 +920,9 @@ private AppConfig BuildConfig()
HideStrategy = SelectedHideStrategy?.Value ?? "disconnect",
FullscreenMode = SelectedLaunch?.Value ?? "bigPicture",
PlaynitePath = PlaynitePath,
+ CustomAppPath = CustomApp.CleanPath(CustomAppPath),
+ CustomAppArguments = CustomAppArguments.Trim(),
+ CustomAppWatchProcess = CustomAppWatchProcess.Trim(),
AudioDeviceId = auto ? "" : audioId,
AudioDeviceName = auto ? "" : SelectedAudio?.Text ?? "",
AudioAutoSwitch = auto,
@@ -994,7 +1010,7 @@ private void UpdateSummary()
: LocalizationService.Get("SummaryFocusOnly", FocusRow.Name);
SummaryItems.Clear();
- SummaryItems.Add(SelectedLaunch?.Text ?? LocalizationService.Get("LaunchSteam"));
+ SummaryItems.Add(LaunchSummaryText);
switch (SelectedAudio?.Value)
{
case null or "":
@@ -1023,6 +1039,7 @@ private static string JoinLocalized(IReadOnlyList values)
{
"playnite" => LocalizationService.Get("ActivePlaynite"),
"xboxMode" => LocalizationService.Get("ActiveXbox"),
+ CustomApp.Mode => LocalizationService.Get("ActiveCustomApp", LaunchService.GetCustomAppName(config.CustomAppPath)),
_ => LocalizationService.Get("ActiveSteam")
};
diff --git a/src/ConsoleMode/Views/ConsoleHomeView.xaml b/src/ConsoleMode/Views/ConsoleHomeView.xaml
index f4dc119..d8902cc 100644
--- a/src/ConsoleMode/Views/ConsoleHomeView.xaml
+++ b/src/ConsoleMode/Views/ConsoleHomeView.xaml
@@ -473,6 +473,12 @@
+