diff --git a/.gitignore b/.gitignore index 0f11773..1d1142d 100644 --- a/.gitignore +++ b/.gitignore @@ -89,3 +89,6 @@ Desktop.ini # 设置文件(如果包含敏感信息) # settings.json + +# 发布/打包输出 +[Dd]ist/ diff --git a/AppSettings.cs b/AppSettings.cs index 29726bf..01930de 100644 --- a/AppSettings.cs +++ b/AppSettings.cs @@ -11,7 +11,7 @@ namespace TimerApp public int IdleThresholdSeconds { get; set; } = 30; public bool IsDarkMode { get; set; } = true; - private static string LegacyConfigPath => Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "settings.json"); + private static string LegacyConfigPath => Path.Combine(AppContext.BaseDirectory, "settings.json"); private static string ConfigPath { @@ -22,11 +22,22 @@ namespace TimerApp } } + private static string EffectiveConfigPath => PortableMode.IsPortable ? LegacyConfigPath : ConfigPath; + public static AppSettings Load() { try { - string path = File.Exists(ConfigPath) ? ConfigPath : LegacyConfigPath; + string path; + if (PortableMode.IsPortable) + { + path = LegacyConfigPath; + } + else + { + path = File.Exists(ConfigPath) ? ConfigPath : LegacyConfigPath; + } + if (File.Exists(path)) { string json = File.ReadAllText(path); @@ -44,9 +55,12 @@ namespace TimerApp { try { - Directory.CreateDirectory(Path.GetDirectoryName(ConfigPath)!); + string path = EffectiveConfigPath; + string? dir = Path.GetDirectoryName(path); + if (!string.IsNullOrWhiteSpace(dir)) + Directory.CreateDirectory(dir); string json = JsonSerializer.Serialize(this, new JsonSerializerOptions { WriteIndented = true }); - File.WriteAllText(ConfigPath, json); + File.WriteAllText(path, json); } catch { diff --git a/NativeMethods.cs b/NativeMethods.cs index bc9637c..78ef4b7 100644 --- a/NativeMethods.cs +++ b/NativeMethods.cs @@ -2,7 +2,6 @@ using System; using System.Runtime.InteropServices; using System.Threading; using System.Threading.Tasks; -using Windows.Media.Control; namespace TimerApp { @@ -80,18 +79,8 @@ namespace TimerApp bool playing = false; try { - var sessionManager = await GlobalSystemMediaTransportControlsSessionManager.RequestAsync(); - var sessions = sessionManager.GetSessions(); - - foreach (var session in sessions) - { - var playbackInfo = session.GetPlaybackInfo(); - if (playbackInfo.PlaybackStatus == GlobalSystemMediaTransportControlsSessionPlaybackStatus.Playing) - { - playing = true; - break; - } - } + await Task.Yield(); + playing = TryIsAudioPlaying(); } catch { @@ -105,5 +94,166 @@ namespace TimerApp } }); } + + private static bool TryIsAudioPlaying() + { + object? deviceEnumeratorObj = null; + IMMDeviceEnumerator? deviceEnumerator = null; + IMMDevice? device = null; + object? sessionManagerObj = null; + IAudioSessionManager2? sessionManager = null; + IAudioSessionEnumerator? sessionEnumerator = null; + + try + { + Type? enumeratorType = Type.GetTypeFromCLSID(CLSID_MMDeviceEnumerator); + if (enumeratorType is null) + return false; + + deviceEnumeratorObj = Activator.CreateInstance(enumeratorType); + if (deviceEnumeratorObj is null) + return false; + deviceEnumerator = (IMMDeviceEnumerator)deviceEnumeratorObj; + + Marshal.ThrowExceptionForHR(deviceEnumerator.GetDefaultAudioEndpoint(EDataFlow.eRender, ERole.eMultimedia, out device)); + + Guid iid = typeof(IAudioSessionManager2).GUID; + Marshal.ThrowExceptionForHR(device.Activate(ref iid, CLSCTX.CLSCTX_ALL, IntPtr.Zero, out sessionManagerObj)); + if (sessionManagerObj is null) + return false; + sessionManager = (IAudioSessionManager2)sessionManagerObj; + + Marshal.ThrowExceptionForHR(sessionManager.GetSessionEnumerator(out sessionEnumerator)); + Marshal.ThrowExceptionForHR(sessionEnumerator.GetCount(out int count)); + + for (int i = 0; i < count; i++) + { + Marshal.ThrowExceptionForHR(sessionEnumerator.GetSession(i, out IAudioSessionControl? sessionControl)); + if (sessionControl is null) + continue; + + try + { + Marshal.ThrowExceptionForHR(sessionControl.GetState(out AudioSessionState state)); + if (state != AudioSessionState.Active) + continue; + + if (sessionControl is IAudioMeterInformation meter) + { + Marshal.ThrowExceptionForHR(meter.GetPeakValue(out float peak)); + if (peak > 0.001f) + return true; + } + else + { + return true; + } + } + finally + { + Marshal.FinalReleaseComObject(sessionControl); + } + } + + return false; + } + catch + { + return false; + } + finally + { + if (sessionEnumerator is not null) Marshal.FinalReleaseComObject(sessionEnumerator); + if (sessionManagerObj is not null) Marshal.FinalReleaseComObject(sessionManagerObj); + if (device is not null) Marshal.FinalReleaseComObject(device); + if (deviceEnumeratorObj is not null) Marshal.FinalReleaseComObject(deviceEnumeratorObj); + } + } + + private static readonly Guid CLSID_MMDeviceEnumerator = new Guid("BCDE0395-E52F-467C-8E3D-C4579291692E"); + + private enum EDataFlow + { + eRender = 0, + eCapture = 1, + eAll = 2 + } + + private enum ERole + { + eConsole = 0, + eMultimedia = 1, + eCommunications = 2 + } + + private enum AudioSessionState + { + Inactive = 0, + Active = 1, + Expired = 2 + } + + [Flags] + private enum CLSCTX : uint + { + CLSCTX_INPROC_SERVER = 0x1, + CLSCTX_INPROC_HANDLER = 0x2, + CLSCTX_LOCAL_SERVER = 0x4, + CLSCTX_REMOTE_SERVER = 0x10, + CLSCTX_ALL = CLSCTX_INPROC_SERVER | CLSCTX_INPROC_HANDLER | CLSCTX_LOCAL_SERVER | CLSCTX_REMOTE_SERVER + } + + [ComImport] + [Guid("A95664D2-9614-4F35-A746-DE8DB63617E6")] + [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + private interface IMMDeviceEnumerator + { + int NotImpl1(); + int GetDefaultAudioEndpoint(EDataFlow dataFlow, ERole role, out IMMDevice ppDevice); + } + + [ComImport] + [Guid("D666063F-1587-4E43-81F1-B948E807363F")] + [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + private interface IMMDevice + { + int Activate(ref Guid iid, CLSCTX dwClsCtx, IntPtr pActivationParams, [MarshalAs(UnmanagedType.IUnknown)] out object ppInterface); + } + + [ComImport] + [Guid("77AA99A0-1BD6-484F-8BC7-2C654C9A9B6F")] + [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + private interface IAudioSessionManager2 + { + int NotImpl1(); + int NotImpl2(); + int GetSessionEnumerator(out IAudioSessionEnumerator sessionEnum); + } + + [ComImport] + [Guid("E2F5BB11-0570-40CA-ACDD-3AA01277DEE8")] + [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + private interface IAudioSessionEnumerator + { + int GetCount(out int sessionCount); + int GetSession(int sessionCount, out IAudioSessionControl session); + } + + [ComImport] + [Guid("F4B1A599-7266-4319-A8CA-E70ACB11E8CD")] + [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + private interface IAudioSessionControl + { + int NotImpl1(); + int GetState(out AudioSessionState state); + } + + [ComImport] + [Guid("C02216F6-8C67-4B5B-9D00-D008E73E0064")] + [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + private interface IAudioMeterInformation + { + int GetPeakValue(out float peak); + } } } diff --git a/PortableMode.cs b/PortableMode.cs new file mode 100644 index 0000000..102d90c --- /dev/null +++ b/PortableMode.cs @@ -0,0 +1,46 @@ +using System; + +namespace TimerApp +{ + internal static class PortableMode + { + private const string PortableEnvVarName = "TIMERAPP_PORTABLE"; + + public static bool IsPortable + { + get + { + try + { + string? env = Environment.GetEnvironmentVariable(PortableEnvVarName); + if (!string.IsNullOrWhiteSpace(env)) + { + if (string.Equals(env, "0", StringComparison.OrdinalIgnoreCase) || + string.Equals(env, "false", StringComparison.OrdinalIgnoreCase) || + string.Equals(env, "no", StringComparison.OrdinalIgnoreCase) || + string.Equals(env, "off", StringComparison.OrdinalIgnoreCase) || + string.Equals(env, "disable", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + if (string.Equals(env, "1", StringComparison.OrdinalIgnoreCase) || + string.Equals(env, "true", StringComparison.OrdinalIgnoreCase) || + string.Equals(env, "yes", StringComparison.OrdinalIgnoreCase) || + string.Equals(env, "on", StringComparison.OrdinalIgnoreCase) || + string.Equals(env, "enable", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + return true; + } + catch + { + return true; + } + } + } + } +} diff --git a/TaskbarIntegration.cs b/TaskbarIntegration.cs index 201759e..42b62e1 100644 --- a/TaskbarIntegration.cs +++ b/TaskbarIntegration.cs @@ -26,6 +26,9 @@ namespace TimerApp public static void InitializeShortcuts() { + if (PortableMode.IsPortable) + return; + string? iconPath = TryEnsureIconFile(); if (iconPath is null) return; diff --git a/TimerApp.csproj b/TimerApp.csproj index 8790a06..a3de757 100644 --- a/TimerApp.csproj +++ b/TimerApp.csproj @@ -1,11 +1,11 @@ - + WinExe - net9.0-windows10.0.17763.0 + net9.0-windows enable true enable - \ No newline at end of file + diff --git a/scripts/publish-portable.cmd b/scripts/publish-portable.cmd new file mode 100644 index 0000000..782c8b3 --- /dev/null +++ b/scripts/publish-portable.cmd @@ -0,0 +1,5 @@ +@echo off +setlocal +powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0publish-portable.ps1" %* +exit /b %errorlevel% + diff --git a/scripts/publish-portable.ps1 b/scripts/publish-portable.ps1 new file mode 100644 index 0000000..f292c50 --- /dev/null +++ b/scripts/publish-portable.ps1 @@ -0,0 +1,91 @@ +param( + [string]$Rid = "win-x64", + [bool]$SelfContained = $false, + [bool]$SingleFile = $true, + [bool]$EnableCompressionInSingleFile = $true, + [bool]$ReadyToRun = $false, + [bool]$Trim = $false, + [bool]$InvariantGlobalization = $false +) + +$ErrorActionPreference = "Stop" +Set-StrictMode -Version Latest + +$repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..") +$project = Join-Path $repoRoot "TimerApp.csproj" + +$artifactsDir = Join-Path $repoRoot "artifacts" +$distDir = Join-Path $repoRoot "dist" + +New-Item -ItemType Directory -Force -Path $distDir | Out-Null + +$EnableCompressionInSingleFile = $EnableCompressionInSingleFile -and $SelfContained + +$variantParts = @() +$variantParts += $(if ($SelfContained) { "sc" } else { "fd" }) +$variantParts += $(if ($SingleFile) { "single" } else { "multi" }) +if ($SingleFile -and $EnableCompressionInSingleFile) { $variantParts += "comp" } +if ($ReadyToRun) { $variantParts += "r2r" } +if ($Trim) { $variantParts += "trim" } +if ($InvariantGlobalization) { $variantParts += "invglob" } +$variant = ($variantParts -join "-") + +$defaultVariant = "fd-single" +$publishBaseDir = Join-Path $artifactsDir "publish" +$publishDir = Join-Path (Join-Path $publishBaseDir $Rid) $variant + +New-Item -ItemType Directory -Force -Path $publishDir | Out-Null + +$props = @( + "-p:SelfContained=$SelfContained", + "-p:PublishSingleFile=$SingleFile", + "-p:PublishReadyToRun=$ReadyToRun", + "-p:DebugType=None", + "-p:DebugSymbols=false" +) + +if ($SingleFile) { + $props += "-p:IncludeNativeLibrariesForSelfExtract=true" + if ($SelfContained) { + $props += "-p:EnableCompressionInSingleFile=$EnableCompressionInSingleFile" + } +} + +if ($Trim) { + $props += "-p:PublishTrimmed=true" + $props += "-p:TrimMode=partial" +} + +if ($InvariantGlobalization) { + $props += "-p:InvariantGlobalization=true" +} + +dotnet publish $project ` + -c Release ` + -r $Rid ` + @props ` + -o $publishDir + +if ($LASTEXITCODE -ne 0) { + throw "dotnet publish failed with exit code $LASTEXITCODE" +} + +$zipName = if ($variant -eq $defaultVariant) { "TimerApp-Portable-$Rid.zip" } else { "TimerApp-Portable-$Rid-$variant.zip" } +$zipPath = Join-Path $distDir $zipName + +if (Test-Path $zipPath) { + try { [System.IO.File]::Delete($zipPath) } catch { } +} + +Compress-Archive -Path (Join-Path $publishDir "*") -DestinationPath $zipPath -Force + +$mainExe = Join-Path $publishDir "TimerApp.exe" +if (Test-Path $mainExe) { + $exeMiB = [Math]::Round(((Get-Item $mainExe).Length / 1MB), 1) + Write-Host "EXE size: $exeMiB MiB" +} +$zipMiB = [Math]::Round(((Get-Item $zipPath).Length / 1MB), 1) +Write-Host "ZIP size: $zipMiB MiB" + +Write-Host "Portable zip created:" +Write-Host " $zipPath"