feat: 添加便携模式和打包脚本,精简打包大小
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -89,3 +89,6 @@ Desktop.ini
|
|||||||
|
|
||||||
# 设置文件(如果包含敏感信息)
|
# 设置文件(如果包含敏感信息)
|
||||||
# settings.json
|
# settings.json
|
||||||
|
|
||||||
|
# 发布/打包输出
|
||||||
|
[Dd]ist/
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ namespace TimerApp
|
|||||||
public int IdleThresholdSeconds { get; set; } = 30;
|
public int IdleThresholdSeconds { get; set; } = 30;
|
||||||
public bool IsDarkMode { get; set; } = true;
|
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
|
private static string ConfigPath
|
||||||
{
|
{
|
||||||
@@ -22,11 +22,22 @@ namespace TimerApp
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static string EffectiveConfigPath => PortableMode.IsPortable ? LegacyConfigPath : ConfigPath;
|
||||||
|
|
||||||
public static AppSettings Load()
|
public static AppSettings Load()
|
||||||
{
|
{
|
||||||
try
|
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))
|
if (File.Exists(path))
|
||||||
{
|
{
|
||||||
string json = File.ReadAllText(path);
|
string json = File.ReadAllText(path);
|
||||||
@@ -44,9 +55,12 @@ namespace TimerApp
|
|||||||
{
|
{
|
||||||
try
|
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 });
|
string json = JsonSerializer.Serialize(this, new JsonSerializerOptions { WriteIndented = true });
|
||||||
File.WriteAllText(ConfigPath, json);
|
File.WriteAllText(path, json);
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
|
|||||||
176
NativeMethods.cs
176
NativeMethods.cs
@@ -2,7 +2,6 @@ using System;
|
|||||||
using System.Runtime.InteropServices;
|
using System.Runtime.InteropServices;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Windows.Media.Control;
|
|
||||||
|
|
||||||
namespace TimerApp
|
namespace TimerApp
|
||||||
{
|
{
|
||||||
@@ -80,18 +79,8 @@ namespace TimerApp
|
|||||||
bool playing = false;
|
bool playing = false;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var sessionManager = await GlobalSystemMediaTransportControlsSessionManager.RequestAsync();
|
await Task.Yield();
|
||||||
var sessions = sessionManager.GetSessions();
|
playing = TryIsAudioPlaying();
|
||||||
|
|
||||||
foreach (var session in sessions)
|
|
||||||
{
|
|
||||||
var playbackInfo = session.GetPlaybackInfo();
|
|
||||||
if (playbackInfo.PlaybackStatus == GlobalSystemMediaTransportControlsSessionPlaybackStatus.Playing)
|
|
||||||
{
|
|
||||||
playing = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
catch
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
46
PortableMode.cs
Normal file
46
PortableMode.cs
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,6 +26,9 @@ namespace TimerApp
|
|||||||
|
|
||||||
public static void InitializeShortcuts()
|
public static void InitializeShortcuts()
|
||||||
{
|
{
|
||||||
|
if (PortableMode.IsPortable)
|
||||||
|
return;
|
||||||
|
|
||||||
string? iconPath = TryEnsureIconFile();
|
string? iconPath = TryEnsureIconFile();
|
||||||
if (iconPath is null)
|
if (iconPath is null)
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<OutputType>WinExe</OutputType>
|
<OutputType>WinExe</OutputType>
|
||||||
<TargetFramework>net9.0-windows10.0.17763.0</TargetFramework>
|
<TargetFramework>net9.0-windows</TargetFramework>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<UseWindowsForms>true</UseWindowsForms>
|
<UseWindowsForms>true</UseWindowsForms>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
|||||||
5
scripts/publish-portable.cmd
Normal file
5
scripts/publish-portable.cmd
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
@echo off
|
||||||
|
setlocal
|
||||||
|
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0publish-portable.ps1" %*
|
||||||
|
exit /b %errorlevel%
|
||||||
|
|
||||||
91
scripts/publish-portable.ps1
Normal file
91
scripts/publish-portable.ps1
Normal file
@@ -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"
|
||||||
Reference in New Issue
Block a user