Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 0 additions & 5 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,6 @@ dist/
artifacts/
release-notes.md

# Ferramentas NirSoft (baixadas pelo build ou colocadas localmente em dev)
MultiMonitorTool.exe
MultiMonitorTool.chm
SoundVolumeView.exe
SoundVolumeView.chm
rtss-cli.exe
readme.txt

Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.en-US.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]
### Changes
- 1.6 is all the project's own code: the option to go back to MultiMonitorTool / SoundVolumeView is gone too, and their old files in the `tools` folder are deleted when the app opens. (#91)

## [1.6.0-alpha.1]
### What's new
- Displays and audio are now controlled through Windows' own APIs. The app no longer bundles NirSoft's MultiMonitorTool or SoundVolumeView: the package holds only the project's code and rtss-cli (MIT), which paves the way for a signed executable and fewer antivirus false positives. (#91, #97, #98, #99)
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]
### Mudanças
- A 1.6 é só código do projeto: saiu também a opção de voltar ao MultiMonitorTool / SoundVolumeView, e os arquivos antigos deles na pasta `tools` são apagados ao abrir o app. (#91)

## [1.6.0-alpha.1]
### Novidades
- Telas e áudio agora são controlados pelas APIs do próprio Windows. O app não embute mais o MultiMonitorTool nem o SoundVolumeView, da NirSoft: o pacote fica só com código do projeto e o rtss-cli (MIT), o que abre caminho para assinar o executável e reduz alertas falsos de antivírus. (#91, #97, #98, #99)
Expand Down
19 changes: 9 additions & 10 deletions docs/TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,26 +114,26 @@ Pré-condições: Ajustes → Interface = **Automático** (padrão).
- [ ] Atualização: com uma release mais nova no GitHub, o aviso aparece nas duas interfaces (na Console, como banner no topo). Resultado: ______
- [ ] Atalho "Console Mode 1 Click" e `--tray` no início do Windows continuam funcionando. Resultado: ______

## Áudio nativo, sem SoundVolumeView (issue #91)
## Áudio com código próprio (issue #91)

Pré-condições: a partir da 1.6 o áudio nativo já vem ligado (`"NativeAudio": true` em `config.json`) e o SoundVolumeView não é mais embutido. O log (`consolemode.log`) deve mostrar `audio=native` na linha `Startup:`. Para comparar com o SoundVolumeView, coloque o `SoundVolumeView.exe` na pasta `tools` e troque para `false`.
Pré-condições: 1.6 instalada por cima da 1.5, com a configuração da 1.5 (saída de áudio escolhida). A 1.6 não usa mais o SoundVolumeView; o arquivo antigo em `tools` é apagado ao abrir o app.

- [ ] Ajustes → Saída de áudio lista as mesmas saídas que com o SoundVolumeView, com os mesmos nomes, e as desativadas aparecem com o sufixo "[Desabilitado]". Resultado: ______
- [ ] A saída que já estava escolhida com o SoundVolumeView continua selecionada (o ID salvo é o mesmo). Resultado: ______
- [ ] Ajustes → Saída de áudio lista as mesmas saídas que na 1.5, com os mesmos nomes, e as desativadas aparecem com o sufixo "[Desabilitado]". Resultado: ______
- [ ] A saída que já estava escolhida com o SoundVolumeView continua selecionada depois de atualizar da 1.5 (o ID salvo é o mesmo). Resultado: ______
- [ ] Entrar no modo console com uma saída fixa (ex.: HDMI da TV): o som passa para ela, inclusive em apps de chamada (papel "comunicações"). O log mostra `Áudio: saída padrão = …`. Resultado: ______
- [ ] "A que aparecer ao conectar (TV)" com a TV começando **desligada**: quando a TV liga, o som vai para o HDMI dela. Resultado: ______
- [ ] Uma saída **desabilitada** em Configurações → Som: escolhê-la liga a saída e o som vai para ela. Resultado: ______
- [ ] Menu da sessão (Select + Y): o volume inicial é o do Windows; ◀/▶ muda de 5 em 5 e tira o mudo; A alterna o mudo. Resultado: ______
- [ ] Ao restaurar a mesa, o som volta para a saída de antes do modo console. Resultado: ______
- [ ] Com `"NativeAudio": true` e **sem** o `SoundVolumeView.exe` na pasta `tools`, tudo acima continua funcionando. Resultado: ______
- [ ] A pasta `tools` não tem mais `SoundVolumeView.exe` nem `MultiMonitorTool.exe` depois de abrir o app. Resultado: ______

## Telas nativas, sem MultiMonitorTool (issue #91)
## Telas com código próprio (issue #91)

Pré-condições: a partir da 1.6 as telas nativas já vêm ligadas (`"NativeDisplays": true` em `config.json`) e o MultiMonitorTool não é mais embutido. A linha `Startup:` do log deve mostrar `displays=native`. Para comparar com o MultiMonitorTool, coloque o `MultiMonitorTool.exe` na pasta `tools` e troque para `false` (ou use a 1.5.0).
Pré-condições: 1.6 instalada por cima da 1.5, com a configuração da 1.5 (tela de jogo e telas a esconder escolhidas). Anote antes, na 1.5, como o mapa de telas aparece, para comparar. A 1.6 não usa mais o MultiMonitorTool; o arquivo antigo em `tools` é apagado ao abrir o app.

### Lista e identidade

- [ ] O mapa de telas mostra os mesmos monitores que com o MultiMonitorTool, com os mesmos nomes, resoluções e posições, inclusive a TV **desligada/desconectada**. Resultado: ______
- [ ] O mapa de telas mostra os mesmos monitores que na 1.5, com os mesmos nomes, resoluções e posições, inclusive a TV **desligada/desconectada**. Resultado: ______
- [ ] A tela de jogo e as telas a esconder que já estavam salvas continuam marcadas (o ID estável é o mesmo). Resultado: ______
- [ ] A lista de resoluções de cada tela continua igual. Resultado: ______

Expand All @@ -154,5 +154,4 @@ Pré-condições: a partir da 1.6 as telas nativas já vêm ligadas (`"NativeDis

- [ ] Big Picture ou Playnite abrindo na tela errada é movido para a TV. Resultado: ______
- [ ] Fechar o app no meio da sessão e abrir de novo: "Restaurar setup" traz a mesa de volta a partir do backup. Resultado: ______
- [ ] Um backup antigo, feito com o MultiMonitorTool, também é restaurado com `"NativeDisplays": true`. Resultado: ______
- [ ] Com `"NativeDisplays": true` e **sem** o `MultiMonitorTool.exe` na pasta `tools`, tudo acima continua funcionando. Resultado: ______
- [ ] Um backup da mesa feito pela 1.5 (sessão iniciada na 1.5, app atualizado antes de restaurar) é restaurado pela 1.6. Resultado: ______
2 changes: 1 addition & 1 deletion src/ConsoleMode/App.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ protected override async void OnLaunched(LaunchActivatedEventArgs args)
AppLog.Write("Controles: " + ControllerInput.DescribeDevices().ReplaceLineEndings(" | "));
ProtocolService.EnsureRegistered();
AppLog.Write($"Startup: exe={Environment.ProcessPath}, args={string.Join(' ', Environment.GetCommandLineArgs().Skip(1))}, " +
$"mmt={AppPaths.HasMmt}, displays={(MonitorService.UseNative ? "native" : "mmt")}, svv={AppPaths.HasSvv}, audio={(AudioService.UseNative ? "native" : "svv")}, rtss-cli={AppPaths.HasRtssCli}");
$"rtss-cli={AppPaths.HasRtssCli}");

ViewModel = new MainViewModel();
_window = new MainWindow(ViewModel);
Expand Down
4 changes: 0 additions & 4 deletions src/ConsoleMode/Models/Models.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,6 @@ public sealed class AppConfig
public string AudioDeviceId { get; set; } = "";
public string AudioDeviceName { get; set; } = "";
public bool AudioAutoSwitch { get; set; }
/// <summary>Core Audio instead of SoundVolumeView (issue #91). On since 1.6; false needs SoundVolumeView.exe in tools.</summary>
public bool NativeAudio { get; set; } = true;
/// <summary>Windows' display APIs instead of MultiMonitorTool (issue #91). On since 1.6; false needs MultiMonitorTool.exe in tools.</summary>
public bool NativeDisplays { get; set; } = true;
public int FpsLimit { get; set; }
public Dictionary<string, SavedDisplayMode> MonitorModes { get; set; } = new(StringComparer.OrdinalIgnoreCase);
public bool HdrEnable { get; set; }
Expand Down
2 changes: 0 additions & 2 deletions src/ConsoleMode/Resources/Strings.en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -145,12 +145,10 @@
"FpsUnavailable": "Optional. Install RivaTuner Statistics Server (RTSS) to use this feature.",
"NoGameDisplayConfigured": "No gaming display is configured; unable to enter console mode.",
"NoDisplayTryRefresh": "No displays found. Try refreshing.",
"MmtMissing": "MultiMonitorTool.exe was not found at {0}",
"ListDisplaysError": "Could not list displays: {0}",
"DisplayNoChange": "Don't change",
"AudioNoChange": "Don't change",
"AudioOnConnect": "The one that appears when connected (TV)",
"SvvMissing": "SoundVolumeView.exe was not found in ConsoleMode_Data/tools; audio switching is disabled.",
"ShortcutCreated": "Created the \"Console Mode 1 Click\" desktop shortcut.",
"ShortcutCreateError": "Could not create shortcut: {0}",
"OpenFolderError": "Could not open folder: {0}",
Expand Down
2 changes: 0 additions & 2 deletions src/ConsoleMode/Resources/Strings.es-ES.json
Original file line number Diff line number Diff line change
Expand Up @@ -145,12 +145,10 @@
"FpsUnavailable": "Opcional. Instala RivaTuner Statistics Server (RTSS) para usar esta función.",
"NoGameDisplayConfigured": "No hay ninguna pantalla de juego configurada; no se puede entrar en modo consola.",
"NoDisplayTryRefresh": "No se encontraron pantallas. Prueba a actualizar.",
"MmtMissing": "No se encontró MultiMonitorTool.exe en {0}",
"ListDisplaysError": "No se pudieron listar las pantallas: {0}",
"DisplayNoChange": "No cambiar",
"AudioNoChange": "No cambiar",
"AudioOnConnect": "La que aparece al conectar (TV)",
"SvvMissing": "No se encontró SoundVolumeView.exe en ConsoleMode_Data/tools; el cambio de audio está desactivado.",
"ShortcutCreated": "Se creó el acceso directo \"Console Mode 1 Click\" en el escritorio.",
"ShortcutCreateError": "No se pudo crear el acceso directo: {0}",
"OpenFolderError": "No se pudo abrir la carpeta: {0}",
Expand Down
2 changes: 0 additions & 2 deletions src/ConsoleMode/Resources/Strings.pt-BR.json
Original file line number Diff line number Diff line change
Expand Up @@ -145,12 +145,10 @@
"FpsUnavailable": "Opcional. Instale o RivaTuner Statistics Server (RTSS) para usar.",
"NoGameDisplayConfigured": "Nenhuma tela de jogo configurada; não foi possível entrar no modo console.",
"NoDisplayTryRefresh": "Nenhuma tela encontrada. Tente atualizar.",
"MmtMissing": "MultiMonitorTool.exe não encontrado em {0}",
"ListDisplaysError": "Não foi possível listar as telas: {0}",
"DisplayNoChange": "Não alterar",
"AudioNoChange": "Não alterar",
"AudioOnConnect": "A que aparecer ao conectar (TV)",
"SvvMissing": "SoundVolumeView.exe não encontrado em ConsoleMode_Data/tools; a troca de áudio fica desativada.",
"ShortcutCreated": "Atalho \"Console Mode 1 Click\" criado na Área de Trabalho.",
"ShortcutCreateError": "Não foi possível criar o atalho: {0}",
"OpenFolderError": "Não foi possível abrir a pasta: {0}",
Expand Down
24 changes: 10 additions & 14 deletions src/ConsoleMode/Services/AppPaths.cs
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,6 @@ public static class AppPaths
public static string ExeDir { get; private set; } = AppContext.BaseDirectory.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
public static string DataDir { get; private set; } = "";
public static string ToolsDir { get; private set; } = "";
public static string MmtPath { get; private set; } = "";
public static string SvvPath { get; private set; } = "";
public static string RtssCliPath { get; private set; } = "";
public static string ConfigPath { get; private set; } = "";
public static string BackupMonitorConfig { get; private set; } = "";
Expand Down Expand Up @@ -93,19 +91,19 @@ public static void Initialize()
EmbeddedFiles.ExtractTools(ToolsDir);
EmbeddedFiles.ExtractIcon(Path.Combine(DataDir, "icon.ico"));

foreach (var name in new[] { "MultiMonitorTool.exe", "SoundVolumeView.exe", "rtss-cli.exe" })
var rtssBeside = Path.Combine(ExeDir, "rtss-cli.exe");
var rtssDest = Path.Combine(ToolsDir, "rtss-cli.exe");
if (!File.Exists(rtssDest) && File.Exists(rtssBeside))
{
var dest = Path.Combine(ToolsDir, name);
if (File.Exists(dest)) continue;
var beside = Path.Combine(ExeDir, name);
if (File.Exists(beside))
{
try { File.Copy(beside, dest, true); } catch { /* ignore */ }
}
try { File.Copy(rtssBeside, rtssDest, true); } catch { /* ignore */ }
}

// Up to 1.5 the NirSoft tools were extracted here; 1.6 is all our own code (issue #91).
foreach (var name in new[] { "MultiMonitorTool.exe", "SoundVolumeView.exe" })
{
try { File.Delete(Path.Combine(ToolsDir, name)); } catch { /* in use or read-only: harmless */ }
}

MmtPath = FirstExisting(Path.Combine(ToolsDir, "MultiMonitorTool.exe"), Path.Combine(ExeDir, "MultiMonitorTool.exe"));
SvvPath = FirstExisting(Path.Combine(ToolsDir, "SoundVolumeView.exe"), Path.Combine(ExeDir, "SoundVolumeView.exe"));
RtssCliPath = FirstExisting(Path.Combine(ToolsDir, "rtss-cli.exe"), Path.Combine(ExeDir, "rtss-cli.exe"));

ConfigPath = Path.Combine(DataDir, "config.json");
Expand All @@ -130,8 +128,6 @@ public static void Initialize()
}
}

public static bool HasMmt => File.Exists(MmtPath);
public static bool HasSvv => File.Exists(SvvPath);
public static bool HasRtssCli => File.Exists(RtssCliPath);

private static string FirstExisting(params string[] paths)
Expand Down
105 changes: 13 additions & 92 deletions src/ConsoleMode/Services/AudioService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,47 +5,24 @@ namespace ConsoleMode.Services;

public sealed class AudioService
{
private static bool? _useNative;
private List<AudioDevice>? _cache;
private string? _cachedDefaultId;
// Native backend: friendly ID (what config and backups store) -> Core Audio endpoint ID.
// Friendly ID (what config and backups store) -> Core Audio endpoint ID.
private Dictionary<string, string> _endpointIds = new(StringComparer.OrdinalIgnoreCase);

/// <summary>
/// Core Audio instead of SoundVolumeView (issue #91). On by default since 1.6; <c>"NativeAudio": false</c>
/// in config.json goes back to SoundVolumeView, which is no longer bundled (put it in the tools folder).
/// </summary>
public static bool UseNative => _useNative ??= LoadNativeFlag();

/// <summary>Audio switching works: the native backend is on, or SoundVolumeView is present.</summary>
public static bool IsAvailable => UseNative || AppPaths.HasSvv;

private static bool LoadNativeFlag()
{
try { return ConfigService.Load().NativeAudio; }
catch { return false; }
}

public void ClearCache()
{
_cache = null;
_cachedDefaultId = null;
}

public int InvokeSvv(params string[] args)
{
if (!AppPaths.HasSvv) throw new InvalidOperationException(LocalizationService.Get("SvvMissing"));
return ProcessRunner.Run(AppPaths.SvvPath, args);
}

public IReadOnlyList<AudioDevice> GetDevices(bool forceRefresh = false)
{
if (!IsAvailable) return [];
if (!forceRefresh && _cache is not null) return _cache;

try
{
var devices = UseNative ? ReadNativeDevices() : ReadSvvDevices();
var devices = ReadNativeDevices();
_cache = [.. devices.OrderBy(d => d.IsActive ? 0 : 1).ThenBy(d => d.Name)];
}
catch (Exception ex)
Expand Down Expand Up @@ -80,39 +57,6 @@ private List<AudioDevice> ReadNativeDevices()
return devices;
}

private List<AudioDevice> ReadSvvDevices()
{
var csvPath = Path.Combine(Path.GetTempPath(), $"consolemode_audio_{Guid.NewGuid():N}.csv");
try
{
InvokeSvv("/ShowDisabledDevices", "1", "/ShowUnpluggedDevices", "1", "/scomma", csvPath);
if (!File.Exists(csvPath)) return [];

var devices = new List<AudioDevice>();
foreach (var row in CsvReader.Read(csvPath))
{
var friendlyId = row.Get("Command-Line Friendly ID");
if (string.IsNullOrWhiteSpace(friendlyId)) continue;
if (!string.Equals(row.Get("Type"), "Device", StringComparison.OrdinalIgnoreCase)) continue;
if (!string.Equals(row.Get("Direction"), "Render", StringComparison.OrdinalIgnoreCase)) continue;

var deviceState = row.Get("Device State");
devices.Add(new AudioDevice
{
Name = AudioNaming.DisplayName(row.Get("Name"), row.Get("Device Name")),
FriendlyId = friendlyId,
IsDefault = row.Get("Default").Contains("Render", StringComparison.OrdinalIgnoreCase),
IsActive = deviceState.Contains("Active", StringComparison.OrdinalIgnoreCase) || string.IsNullOrWhiteSpace(deviceState)
});
}
return devices;
}
finally
{
try { File.Delete(csvPath); } catch { /* ignore */ }
}
}

public string? GetDefaultId()
{
if (_cachedDefaultId is not null) return _cachedDefaultId;
Expand All @@ -121,59 +65,36 @@ private List<AudioDevice> ReadSvvDevices()

public void SetOutput(string friendlyId)
{
if (UseNative)
{
var endpointId = ResolveEndpoint(friendlyId);
if (GetDevices().FirstOrDefault(d => d.FriendlyId == friendlyId) is { IsActive: false })
CoreAudio.Enable(endpointId);
CoreAudio.SetDefault(endpointId);
}
else
{
InvokeSvv("/Enable", friendlyId);
InvokeSvv("/SetDefault", friendlyId, "all");
}
var endpointId = ResolveEndpoint(friendlyId);
if (GetDevices().FirstOrDefault(d => d.FriendlyId == friendlyId) is { IsActive: false })
CoreAudio.Enable(endpointId);
CoreAudio.SetDefault(endpointId);
AppLog.Write($"Áudio: saída padrão = {friendlyId}");
_cachedDefaultId = friendlyId;
_cache = null;
}

public void Restore(string? backupId)
{
if (string.IsNullOrWhiteSpace(backupId) || !IsAvailable) return;
try
{
if (UseNative) CoreAudio.SetDefault(ResolveEndpoint(backupId));
else InvokeSvv("/SetDefault", backupId, "all");
}
if (string.IsNullOrWhiteSpace(backupId)) return;
try { CoreAudio.SetDefault(ResolveEndpoint(backupId)); }
catch (Exception ex) { AppLog.Write($"Não foi possível restaurar o áudio: {ex.Message}"); }
}

/// <summary>Output volume from 0 to 100, or null when it can't be read.</summary>
public int? GetVolumePercent(string friendlyId) => UseNative
? CoreAudio.GetVolumePercent(ResolveEndpoint(friendlyId))
: SessionMenuMath.ParseVolumeExitCode(InvokeSvv("/GetPercent", friendlyId));
public int? GetVolumePercent(string friendlyId) => CoreAudio.GetVolumePercent(ResolveEndpoint(friendlyId));

/// <summary>Sets the volume and unmutes, like turning the knob.</summary>
public void SetVolumePercent(string friendlyId, int percent)
{
if (UseNative)
{
var endpointId = ResolveEndpoint(friendlyId);
CoreAudio.SetVolumePercent(endpointId, percent);
CoreAudio.SetMute(endpointId, false);
}
else
{
InvokeSvv("/SetVolume", friendlyId, percent.ToString());
InvokeSvv("/Unmute", friendlyId);
}
var endpointId = ResolveEndpoint(friendlyId);
CoreAudio.SetVolumePercent(endpointId, percent);
CoreAudio.SetMute(endpointId, false);
}

public void SetMute(string friendlyId, bool mute)
{
if (UseNative) CoreAudio.SetMute(ResolveEndpoint(friendlyId), mute);
else InvokeSvv(mute ? "/Mute" : "/Unmute", friendlyId);
CoreAudio.SetMute(ResolveEndpoint(friendlyId), mute);
}

private string ResolveEndpoint(string friendlyId)
Expand Down
Loading
Loading