mirror of
https://github.com/devmobasa/wayscriber.git
synced 2026-11-01 07:36:42 +01:00
549 lines
32 KiB
C#
549 lines
32 KiB
C#
namespace Wayscriber.Tools;
|
|
|
|
internal static class NativeDesktopCommands
|
|
{
|
|
private const uint RootUserId = 0;
|
|
private const int DownloadTimeoutSeconds = 60;
|
|
private const int DaemonRestartDelayMilliseconds = 500;
|
|
private const string GlsVersion = "1.3.0";
|
|
private const string GlsCommit = "1c963c51514581c41b9bdae08cdf69171265cdda";
|
|
private const string GlsArchiveSha256 = "22be5f5edf487cfb87266f0e71c400b11322082a4dc99832e5a54a4fca3d5a7c";
|
|
private static readonly int[] ConfiguratorIconSizes = [16, 19, 22, 24, 38, 64, 128];
|
|
|
|
public static IReadOnlyList<ToolCommand> Commands
|
|
{
|
|
get;
|
|
} =
|
|
[
|
|
new( CommandAreas.Elf, CommandNames.VerifyStatic, "Verify static gtk4-layer-shell linkage.", SideEffect.ReadOnly, VerifyStatic ),
|
|
new( CommandAreas.Elf, CommandNames.VerifyDynamic, "Verify dynamic gtk4-layer-shell linkage.", SideEffect.ReadOnly, VerifyDynamic ),
|
|
new( CommandAreas.Native, CommandNames.InstallGtk4LayerShell, "Build the pinned gtk4-layer-shell source.", SideEffect.MachineMutating, InstallGtkLayerShell ),
|
|
new( CommandAreas.Desktop, CommandNames.SetPortalShortcut, "Set the portal shortcut service environment.", SideEffect.MachineMutating, SetPortalShortcut ),
|
|
new( CommandAreas.Desktop, CommandNames.ReloadDaemon, "Restart the Wayscriber daemon.", SideEffect.MachineMutating, ReloadDaemon ),
|
|
new( CommandAreas.Install, CommandNames.App, "Build and install Wayscriber locally.", SideEffect.MachineMutating, InstallApp ),
|
|
new( CommandAreas.Install, CommandNames.Configurator, "Build and install the configurator locally.", SideEffect.MachineMutating, InstallConfigurator ),
|
|
];
|
|
|
|
private static async Task<int> VerifyStatic( ToolContext context, string[] args )
|
|
{
|
|
var binary = Path.GetFullPath( new Arguments( args ).SinglePositional( "elf verify-static BINARY" ), Environment.CurrentDirectory );
|
|
if ( !File.Exists( binary ) )
|
|
{
|
|
throw new ToolException( $"Missing Wayscriber binary: {binary}" );
|
|
}
|
|
var dynamic = await context.Run( Programs.ReadElf, ["-d", binary], capture: true );
|
|
if ( dynamic.StandardOutput.Contains( "Shared library: [libgtk4-layer-shell.so", StringComparison.Ordinal ) )
|
|
{
|
|
throw new ToolException( $"{binary} still dynamically requires gtk4-layer-shell" );
|
|
}
|
|
if ( !dynamic.StandardOutput.Contains( "Shared library: [libwayland-client.so.0]", StringComparison.Ordinal ) )
|
|
{
|
|
throw new ToolException( $"{binary} does not retain libwayland-client.so.0 in DT_NEEDED" );
|
|
}
|
|
var symbols = await context.Run( Programs.Nm, ["-D", "--defined-only", binary], capture: true );
|
|
var names = symbols.StandardOutput.Split( '\n', StringSplitOptions.RemoveEmptyEntries )
|
|
.Select( line => line.Split( ' ', StringSplitOptions.RemoveEmptyEntries ).LastOrDefault( ) ).ToHashSet( StringComparer.Ordinal );
|
|
foreach ( var symbol in new[] { "wl_proxy_destroy", "wl_proxy_marshal_array_flags", "wl_proxy_marshal_flags", "wl_proxy_marshal",
|
|
"wl_proxy_marshal_array", "wl_proxy_marshal_constructor", "wl_proxy_marshal_constructor_versioned",
|
|
"wl_proxy_marshal_array_constructor", "wl_proxy_marshal_array_constructor_versioned" } )
|
|
{
|
|
if ( !names.Contains( symbol ) )
|
|
{
|
|
throw new ToolException( $"{binary} does not export required gtk4-layer-shell shim {symbol}" );
|
|
}
|
|
}
|
|
await context.Output.WriteLineAsync( $"Verified static gtk4-layer-shell linkage and exported Wayland shims in {binary}" );
|
|
return ExitCodes.Success;
|
|
}
|
|
|
|
private static async Task<int> VerifyDynamic( ToolContext context, string[] args )
|
|
{
|
|
var binary = Path.GetFullPath( new Arguments( args ).SinglePositional( "elf verify-dynamic BINARY" ), Environment.CurrentDirectory );
|
|
if ( !File.Exists( binary ) )
|
|
{
|
|
throw new ToolException( $"Missing Wayscriber binary: {binary}" );
|
|
}
|
|
var dynamic = await context.Run( Programs.ReadElf, ["-d", binary], capture: true );
|
|
if ( !dynamic.StandardOutput.Contains( "Shared library: [libgtk4-layer-shell.so", StringComparison.Ordinal ) )
|
|
{
|
|
throw new ToolException( $"{binary} does not dynamically require gtk4-layer-shell" );
|
|
}
|
|
await context.Output.WriteLineAsync( $"Verified dynamic gtk4-layer-shell linkage in {binary}" );
|
|
return ExitCodes.Success;
|
|
}
|
|
|
|
private static async Task<int> InstallGtkLayerShell( ToolContext context, string[] args )
|
|
{
|
|
var parsed = new Arguments( args );
|
|
var prefix = parsed.TakeOption( "--prefix" ) ?? context.Environment( EnvironmentVariables.Gtk4LayerShellPrefix ) ?? "/usr";
|
|
var systemPrefix = parsed.TakeOption( "--system-prefix" ) ?? context.Environment( EnvironmentVariables.Gtk4LayerShellSystemPrefix ) ?? "/usr";
|
|
var mode = parsed.TakeOption( "--library-mode" ) ?? context.Environment( EnvironmentVariables.Gtk4LayerShellLibraryMode ) ?? NativeLibraryModes.Shared;
|
|
var force = parsed.TakeFlag( "--force" ) || context.Environment( EnvironmentVariables.ForceBuild ) == EnvironmentVariables.Enabled;
|
|
parsed.RequireEmpty( "native install-gtk4-layer-shell [--prefix PATH] [--library-mode shared|static|both] [--force]" );
|
|
if ( !Path.IsPathFullyQualified( prefix ) || !Path.IsPathFullyQualified( systemPrefix ) )
|
|
{
|
|
throw new ToolException( "gtk4-layer-shell prefixes must be absolute." );
|
|
}
|
|
if ( mode is not (NativeLibraryModes.Shared or NativeLibraryModes.Static or NativeLibraryModes.Both) )
|
|
{
|
|
throw new ToolException( "library mode must be shared, static, or both." );
|
|
}
|
|
var pin = $"version={GlsVersion}\ncommit={GlsCommit}\narchive_sha256={GlsArchiveSha256}\nlibrary_mode={mode}\n";
|
|
var stamp = Path.Combine( prefix, "share/wayscriber/build-deps/gtk4-layer-shell.pin" );
|
|
var pkgEnvironment = new Dictionary<string, string?>
|
|
{
|
|
[EnvironmentVariables.PackageConfigPath] = Path.Combine( prefix, "lib/pkgconfig" ) + (context.Environment( EnvironmentVariables.PackageConfigPath ) is { Length: > 0 } existing ? $":{existing}" : string.Empty),
|
|
};
|
|
if ( !force && await GtkArtifactsExist( context, prefix, systemPrefix, mode, stamp, pin, pkgEnvironment ) )
|
|
{
|
|
var version = await context.Run( Programs.PkgConfig, ["--modversion", "gtk4-layer-shell-0"], environment: pkgEnvironment, capture: true );
|
|
await context.Output.WriteLineAsync( $"[install-gtk4-layer-shell] gtk4-layer-shell {version.StandardOutput.Trim( )} ({mode}) already available in {prefix}; skipping build" );
|
|
return ExitCodes.Success;
|
|
}
|
|
using var temporary = new TemporaryDirectory( RepositoryNames.Gtk4LayerShell );
|
|
var archive = Path.Combine( temporary.Path, "source.tar.gz" );
|
|
await context.Output.WriteLineAsync( $"[install-gtk4-layer-shell] Downloading gtk4-layer-shell {GlsVersion} (commit {GlsCommit})" );
|
|
using ( var client = new HttpClient { Timeout = TimeSpan.FromSeconds( DownloadTimeoutSeconds ) } )
|
|
using ( var response = await client.GetAsync( $"https://github.com/wmww/gtk4-layer-shell/archive/{GlsCommit}.tar.gz", HttpCompletionOption.ResponseHeadersRead, context.CancellationToken ) )
|
|
{
|
|
response.EnsureSuccessStatusCode( );
|
|
await using var input = await response.Content.ReadAsStreamAsync( context.CancellationToken );
|
|
await using var output = File.Create( archive );
|
|
await input.CopyToAsync( output, context.CancellationToken );
|
|
}
|
|
if ( Files.Sha256( archive ) != GlsArchiveSha256 )
|
|
{
|
|
throw new ToolException( "gtk4-layer-shell archive checksum mismatch." );
|
|
}
|
|
await context.Run( Programs.Tar, ["-xzf", archive, CommandLineOptions.ChangeDirectory, temporary.Path] );
|
|
var source = Path.Combine( temporary.Path, $"gtk4-layer-shell-{GlsCommit}" );
|
|
if ( !File.ReadAllBytes( Path.Combine( source, "LICENSE" ) ).SequenceEqual( File.ReadAllBytes( context.Path( RepositoryPaths.PackagingDirectory, "licenses", "gtk4-layer-shell.LICENSE" ) ) ) )
|
|
{
|
|
throw new ToolException( "Pinned gtk4-layer-shell LICENSE differs from the tracked notice." );
|
|
}
|
|
var build = Path.Combine( source, "_build" );
|
|
await context.Run( Programs.Meson, ["setup", build, source, $"--prefix={prefix}", "--libdir=lib", "--buildtype=release", $"--default-library={mode}",
|
|
"-Dexamples=false", "-Ddocs=false", "-Dtests=false", "-Dintrospection=false", "-Dvapi=false"] );
|
|
await context.Run( Programs.Meson, ["compile", CommandLineOptions.ChangeDirectory, build] );
|
|
var privileged = prefix == systemPrefix || prefix.StartsWith( systemPrefix.TrimEnd( '/' ) + "/", StringComparison.Ordinal );
|
|
await RunPossiblyRoot( context, privileged, Programs.Meson, ["install", CommandLineOptions.ChangeDirectory, build] );
|
|
var pinFile = Path.Combine( temporary.Path, "gtk4-layer-shell.pin" );
|
|
Files.WriteAtomic( pinFile, pin );
|
|
await RunPossiblyRoot( context, privileged, Programs.Install, [InstallArguments.DataFile, pinFile, stamp] );
|
|
if ( privileged )
|
|
{
|
|
await RunPossiblyRoot( context, true, Programs.LdConfig, [], allowedExitCodes: new HashSet<int> { ExitCodes.Success, ExitCodes.Failure } );
|
|
}
|
|
if ( !await GtkArtifactsExist( context, prefix, systemPrefix, mode, stamp, pin, pkgEnvironment ) )
|
|
{
|
|
throw new ToolException( $"{mode} gtk4-layer-shell artifacts unavailable after install." );
|
|
}
|
|
await context.Output.WriteLineAsync( $"[install-gtk4-layer-shell] Installed gtk4-layer-shell {GlsVersion} ({mode}) to {prefix}" );
|
|
return ExitCodes.Success;
|
|
}
|
|
|
|
internal static async Task<bool> GtkArtifactsExist( ToolContext context, string prefix, string systemPrefix, string mode, string stamp, string pin,
|
|
IReadOnlyDictionary<string, string?> environment )
|
|
{
|
|
if ( File.Exists( stamp ) )
|
|
{
|
|
if ( Files.Read( stamp ) != pin )
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
else if ( prefix != systemPrefix )
|
|
{
|
|
return false;
|
|
}
|
|
|
|
var probe = await context.Run( Programs.PkgConfig, [$"--atleast-version={GlsVersion}", "gtk4-layer-shell-0"], environment: environment,
|
|
capture: true, trace: false, allowedExitCodes: new HashSet<int> { ExitCodes.Success, ExitCodes.Failure } );
|
|
if ( probe.ExitCode != ExitCodes.Success )
|
|
{
|
|
return false;
|
|
}
|
|
|
|
var dirResult = await context.Run( Programs.PkgConfig, ["--variable=libdir", "gtk4-layer-shell-0"], environment: environment, capture: true, trace: false );
|
|
var directory = dirResult.StandardOutput.Trim( );
|
|
if ( !(directory == prefix || directory.StartsWith( prefix.TrimEnd( '/' ) + "/", StringComparison.Ordinal )) )
|
|
{
|
|
return false;
|
|
}
|
|
|
|
var shared = File.Exists( Path.Combine( directory, "libgtk4-layer-shell.so" ) ) || File.Exists( Path.Combine( directory, "libgtk4-layer-shell.so.0" ) );
|
|
var @static = File.Exists( Path.Combine( directory, "libgtk4-layer-shell.a" ) );
|
|
return mode switch
|
|
{
|
|
NativeLibraryModes.Shared => shared,
|
|
NativeLibraryModes.Static => @static,
|
|
_ => shared && @static
|
|
};
|
|
}
|
|
|
|
private static async Task<int> SetPortalShortcut( ToolContext context, string[] args )
|
|
{
|
|
var shortcut = args.Length > 0 ? args[0] : "<Ctrl><Shift>g";
|
|
var appId = args.Length > 1 ? args[1] : RepositoryNames.MainPackage;
|
|
if ( args.Length > 2 )
|
|
{
|
|
throw new ToolException( "desktop set-portal-shortcut [SHORTCUT] [APP_ID]", ExitCodes.InvalidArguments );
|
|
}
|
|
var home = context.Environment( EnvironmentVariables.Home );
|
|
if ( home is null )
|
|
{
|
|
throw new ToolException( ToolMessages.HomeNotSet );
|
|
}
|
|
|
|
var path = Path.Combine( home, ".config/systemd/user/wayscriber.service.d/shortcut.conf" );
|
|
string Escape( string value ) => value.Replace( "\\", "\\\\", StringComparison.Ordinal ).Replace( "\"", "\\\"", StringComparison.Ordinal );
|
|
Files.WriteAtomic( path, $"[Service]\nEnvironment=\"WAYSCRIBER_PORTAL_SHORTCUT={Escape( shortcut )}\"\nEnvironment=\"WAYSCRIBER_PORTAL_APP_ID={Escape( appId )}\"\n" );
|
|
await context.Run( Programs.SystemControl, ["--user", "daemon-reload"] );
|
|
await context.Run( Programs.SystemControl, ["--user", "restart", RepositoryNames.UserServiceFile] );
|
|
await context.Output.WriteLineAsync( $"Updated {path}\nShortcut set to: {shortcut}\nPortal app id set to: {appId}\nwayscriber.service restarted." );
|
|
return ExitCodes.Success;
|
|
}
|
|
|
|
private static async Task<int> ReloadDaemon( ToolContext context, string[] args )
|
|
{
|
|
new Arguments( args ).RequireEmpty( "desktop reload-daemon" );
|
|
await context.Output.WriteLineAsync( "Stopping wayscriber daemon..." );
|
|
await context.Run( Programs.Pkill, [RepositoryNames.MainPackage], allowedExitCodes: new HashSet<int> { ExitCodes.Success, ExitCodes.Failure } );
|
|
await Task.Delay( DaemonRestartDelayMilliseconds, context.CancellationToken );
|
|
await context.Output.WriteLineAsync( "Starting wayscriber daemon..." );
|
|
_ = System.Diagnostics.Process.Start( new System.Diagnostics.ProcessStartInfo( Programs.Wayscriber ) { UseShellExecute = false, ArgumentList = { "--daemon" } } );
|
|
await Task.Delay( DaemonRestartDelayMilliseconds, context.CancellationToken );
|
|
var probe = await context.Run( Programs.Pgrep, ["-x", RepositoryNames.MainPackage], capture: true, allowedExitCodes: new HashSet<int> { ExitCodes.Success, ExitCodes.Failure } );
|
|
if ( probe.ExitCode != ExitCodes.Success )
|
|
{
|
|
throw new ToolException( "Failed to start daemon." );
|
|
}
|
|
await context.Output.WriteLineAsync( $"Daemon restarted successfully (PID: {probe.StandardOutput.Trim( )})\nPress Super+D to toggle overlay" );
|
|
return ExitCodes.Success;
|
|
}
|
|
|
|
private static async Task<int> InstallApp( ToolContext context, string[] args )
|
|
{
|
|
var parsed = new Arguments( args );
|
|
var replaceOther = parsed.TakeFlag( "--replace-other" );
|
|
var skipAutostart = parsed.TakeFlag( "--skip-autostart" );
|
|
var addHyprlandKeybind = parsed.TakeFlag( "--add-hyprland-keybind" );
|
|
var autostart = parsed.TakeOption( "--autostart" );
|
|
var installDir = parsed.TakeOption( "--install-dir" ) ?? context.Environment( EnvironmentVariables.WayscriberInstallDirectory ) ?? "/usr/bin";
|
|
parsed.RequireEmpty( "install app [--install-dir PATH] [--replace-other] [--skip-autostart] [--autostart systemd|hyprland|none] [--add-hyprland-keybind]" );
|
|
ValidateInstallOptions( skipAutostart, autostart );
|
|
|
|
installDir = NormalizeBinDirectory( installDir );
|
|
var destination = Path.Combine( installDir, RepositoryNames.MainPackage );
|
|
var home = context.Environment( EnvironmentVariables.Home );
|
|
if ( home is null )
|
|
{
|
|
throw new ToolException( ToolMessages.HomeNotSet );
|
|
}
|
|
|
|
var systemdUser = Path.Combine( context.Environment( EnvironmentVariables.XdgConfigHome ) ?? Path.Combine( home, ".config" ), "systemd/user" );
|
|
var conflicts = FindInstallConflicts( home, systemdUser, destination );
|
|
await ConfirmInstallConflicts( context, conflicts, destination, replaceOther );
|
|
await context.Run( Programs.Cargo, ["build", CommandLineOptions.Release, CommandLineOptions.Binaries] );
|
|
await RemoveInstallConflicts( context, conflicts );
|
|
|
|
await RunPossiblyRoot( context, NeedsPrivilege( installDir ), Programs.Install, ["-d", installDir] );
|
|
await RunPossiblyRoot( context, NeedsPrivilege( installDir ), Programs.Install,
|
|
[InstallArguments.ExecutableFile, context.Path( RepositoryPaths.TargetDirectory, RepositoryPaths.ReleaseDirectory, RepositoryNames.MainPackage ), destination] );
|
|
var configDir = Path.Combine( home, ".config/wayscriber" );
|
|
Directory.CreateDirectory( configDir );
|
|
var config = Path.Combine( configDir, "config.toml" );
|
|
if ( !File.Exists( config ) )
|
|
{
|
|
File.Copy( context.Path( "config.example.toml" ), config );
|
|
}
|
|
autostart = await SelectAutostart( context, skipAutostart, autostart );
|
|
if ( autostart == AutostartModes.Systemd )
|
|
{
|
|
await InstallSystemdService( context, destination, installDir, systemdUser );
|
|
}
|
|
else if ( autostart == AutostartModes.Hyprland )
|
|
{
|
|
ConfigureHyprland( home, destination, includeAutostart: true );
|
|
}
|
|
if ( autostart == AutostartModes.Systemd && addHyprlandKeybind )
|
|
{
|
|
ConfigureHyprland( home, destination, includeAutostart: false );
|
|
}
|
|
await context.Output.WriteLineAsync( $"Installed: {destination}\nSHA256: {Files.Sha256( destination )}" );
|
|
return ExitCodes.Success;
|
|
}
|
|
|
|
private static void ValidateInstallOptions( bool skipAutostart, string? autostart )
|
|
{
|
|
if ( skipAutostart && autostart is not null )
|
|
{
|
|
throw new ToolException( "--skip-autostart and --autostart cannot be combined.", ExitCodes.InvalidArguments );
|
|
}
|
|
if ( autostart is not null && autostart is not (AutostartModes.Systemd or AutostartModes.Hyprland or AutostartModes.None) )
|
|
{
|
|
throw new ToolException( "--autostart must be systemd, hyprland, or none.", ExitCodes.InvalidArguments );
|
|
}
|
|
}
|
|
|
|
private static string[] FindInstallConflicts( string home, string systemdUser, string destination )
|
|
{
|
|
var known = new[] { "/usr/bin/wayscriber", "/usr/local/bin/wayscriber", Path.Combine( home, ".local/bin/wayscriber" ) };
|
|
var overrideDirectory = Path.Combine( systemdUser, "wayscriber.service.d" );
|
|
var userUnits = new[] { Path.Combine( systemdUser, RepositoryNames.UserServiceFile ) }
|
|
.Concat( Directory.Exists( overrideDirectory ) ? Directory.EnumerateFiles( overrideDirectory, "*.conf" ) : [] );
|
|
var systemUnits = new[] { "/usr/lib/systemd/user/wayscriber.service", "/usr/local/lib/systemd/user/wayscriber.service" };
|
|
var binaries = known.Where( path => path != destination && (File.Exists( path ) || Directory.Exists( path )) && !SameFile( path, destination ) );
|
|
var units = systemUnits.Concat( userUnits ).Where( path => HasConflictingExecStart( path, destination, known ) );
|
|
return binaries.Concat( units ).Distinct( ).ToArray( );
|
|
}
|
|
|
|
private static async Task ConfirmInstallConflicts( ToolContext context, string[] conflicts, string destination, bool replaceOther )
|
|
{
|
|
if ( conflicts.Length == 0 || replaceOther )
|
|
{
|
|
return;
|
|
}
|
|
if ( Console.IsInputRedirected )
|
|
{
|
|
throw new ToolException( $"Another Wayscriber install exists ({string.Join( ", ", conflicts )}); pass --replace-other to replace it." );
|
|
}
|
|
|
|
await context.Output.WriteLineAsync( $"Another Wayscriber install exists:\n{string.Join( '\n', conflicts.Select( path => $" {path}" ) )}" );
|
|
await context.Output.WriteAsync( $"Remove those files so only {destination} remains? [y/N] " );
|
|
var response = Console.ReadLine( );
|
|
var confirmed = response is not null && (response.Equals( "y", StringComparison.OrdinalIgnoreCase ) || response.Equals( "yes", StringComparison.OrdinalIgnoreCase ));
|
|
if ( !confirmed )
|
|
{
|
|
throw new ToolException( "Refusing to install beside the other copy. Re-run with --replace-other after removing it." );
|
|
}
|
|
}
|
|
|
|
private static async Task RemoveInstallConflicts( ToolContext context, string[] conflicts )
|
|
{
|
|
foreach ( var conflict in conflicts )
|
|
{
|
|
if ( await IsPackageOwned( context, conflict ) )
|
|
{
|
|
if ( Path.GetFileName( conflict ) == RepositoryNames.MainPackage )
|
|
{
|
|
throw new ToolException( $"{conflict} is package-owned; remove or update that package instead." );
|
|
}
|
|
await context.Error.WriteLineAsync( $"Leaving package-owned {conflict}." );
|
|
continue;
|
|
}
|
|
|
|
await RunPossiblyRoot( context, NeedsPrivilege( conflict ), Programs.Remove, ["-f", CommandLineOptions.EndOfOptions, conflict] );
|
|
}
|
|
}
|
|
|
|
private static async Task<string> SelectAutostart( ToolContext context, bool skipAutostart, string? autostart )
|
|
{
|
|
if ( skipAutostart || autostart is not null )
|
|
{
|
|
return autostart ?? AutostartModes.None;
|
|
}
|
|
if ( Console.IsInputRedirected )
|
|
{
|
|
throw new ToolException( "Choose --autostart systemd|hyprland|none for a non-interactive install." );
|
|
}
|
|
|
|
await context.Output.WriteAsync( "Autostart method: 1) systemd 2) Hyprland 3) none [1-3]: " );
|
|
return Console.ReadLine( ) switch
|
|
{
|
|
"1" => AutostartModes.Systemd,
|
|
"2" => AutostartModes.Hyprland,
|
|
_ => AutostartModes.None
|
|
};
|
|
}
|
|
|
|
private static async Task<int> InstallConfigurator( ToolContext context, string[] args )
|
|
{
|
|
var parsed = new Arguments( args );
|
|
var installDir = parsed.TakeOption( "--install-dir" ) ?? context.Environment( EnvironmentVariables.WayscriberInstallDirectory ) ?? "/usr/bin";
|
|
var dataDir = parsed.TakeOption( "--data-dir" ) ?? context.Environment( EnvironmentVariables.WayscriberDataDirectory );
|
|
parsed.RequireEmpty( "install configurator [--install-dir PATH] [--data-dir PATH]" );
|
|
installDir = Path.GetFullPath( installDir );
|
|
if ( dataDir is null )
|
|
{
|
|
if ( Path.GetFileName( installDir ) != "bin" )
|
|
{
|
|
throw new ToolException( $"Cannot derive data directory from {installDir}; pass --data-dir." );
|
|
}
|
|
dataDir = Path.Combine( Path.GetDirectoryName( installDir )!, "share" );
|
|
}
|
|
dataDir = Path.GetFullPath( dataDir );
|
|
await context.Run( Programs.Cargo, ["build", CommandLineOptions.Release, CommandLineOptions.Binaries, "--manifest-path", RepositoryPaths.ConfiguratorCargoManifest] );
|
|
var binary = new[] { context.Path( RepositoryPaths.TargetDirectory, RepositoryPaths.ReleaseDirectory, RepositoryNames.ConfiguratorPackage ),
|
|
context.Path( RepositoryPaths.ConfiguratorDirectory, RepositoryPaths.TargetDirectory, RepositoryPaths.ReleaseDirectory, RepositoryNames.ConfiguratorPackage ) }
|
|
.FirstOrDefault( File.Exists );
|
|
if ( binary is null )
|
|
{
|
|
throw new ToolException( "Configurator binary not found after build." );
|
|
}
|
|
|
|
var destination = Path.Combine( installDir, RepositoryNames.ConfiguratorPackage );
|
|
await RunPossiblyRoot( context, NeedsPrivilege( installDir ), Programs.Install, [InstallArguments.ExecutableFile, binary, destination] );
|
|
var desktop = Files.Read( context.Path( RepositoryPaths.PackagingDirectory, "wayscriber-configurator.desktop" ) );
|
|
var escaped = EscapeDesktopExecPath( destination );
|
|
desktop = System.Text.RegularExpressions.Regex.Replace( desktop, @"(?m)^Exec=.*$", $"Exec=\"{escaped}\"" );
|
|
desktop = System.Text.RegularExpressions.Regex.Replace( desktop, @"(?m)^TryExec=.*$", $"TryExec={destination.Replace( "\\", "\\\\", StringComparison.Ordinal )}" );
|
|
using var temporary = new TemporaryDirectory( "wayscriber-desktop" );
|
|
var desktopFile = Path.Combine( temporary.Path, "wayscriber-configurator.desktop" );
|
|
Files.WriteAtomic( desktopFile, desktop );
|
|
await InstallData( context, desktopFile, Path.Combine( dataDir, "applications/wayscriber-configurator.desktop" ) );
|
|
foreach ( var size in ConfiguratorIconSizes )
|
|
{
|
|
await InstallData( context, context.Path( RepositoryPaths.PackagingDirectory, "icons", $"wayscriber-configurator-{size}.png" ), Path.Combine( dataDir, $"icons/hicolor/{size}x{size}/apps/wayscriber-configurator.png" ) );
|
|
}
|
|
await InstallData( context, context.Path( RepositoryPaths.PackagingDirectory, "icons", "wayscriber-configurator.svg" ), Path.Combine( dataDir, "icons/hicolor/scalable/apps/wayscriber-configurator.svg" ) );
|
|
await InstallData( context, context.Path( RepositoryPaths.PackagingDirectory, "icons", "wayscriber-configurator-128.png" ), Path.Combine( dataDir, "pixmaps/wayscriber-configurator.png" ) );
|
|
if ( await CommandExists( context, Programs.UpdateDesktopDatabase ) )
|
|
{
|
|
await RunPossiblyRoot( context, NeedsPrivilege( dataDir ), Programs.UpdateDesktopDatabase, [Path.Combine( dataDir, "applications" )] );
|
|
}
|
|
if ( File.Exists( Path.Combine( dataDir, "icons/hicolor/index.theme" ) ) && await CommandExists( context, Programs.GtkUpdateIconCache ) )
|
|
{
|
|
await RunPossiblyRoot( context, NeedsPrivilege( dataDir ), Programs.GtkUpdateIconCache, ["-q", "-f", "-t", Path.Combine( dataDir, "icons/hicolor" )] );
|
|
}
|
|
await context.Output.WriteLineAsync( $"Configurator installation complete.\nRun: {destination} --help" );
|
|
return ExitCodes.Success;
|
|
}
|
|
|
|
internal static string EscapeDesktopExecPath( string path ) => path.Replace( "\\", "\\\\\\\\", StringComparison.Ordinal )
|
|
.Replace( "\"", "\\\\\"", StringComparison.Ordinal ).Replace( "`", "\\\\`", StringComparison.Ordinal )
|
|
.Replace( "$", "\\\\$", StringComparison.Ordinal ).Replace( "%", "%%", StringComparison.Ordinal );
|
|
|
|
private static async Task InstallSystemdService( ToolContext context, string destination, string installDir, string userDirectory )
|
|
{
|
|
var userService = Path.Combine( userDirectory, RepositoryNames.UserServiceFile );
|
|
var systemService = "/usr/lib/systemd/user/wayscriber.service";
|
|
var target = installDir == "/usr/bin" ? systemService : userService;
|
|
if ( File.Exists( userService ) && target != userService )
|
|
{
|
|
File.Delete( userService );
|
|
}
|
|
var service = Files.Read( context.Path( RepositoryPaths.PackagingDirectory, RepositoryNames.UserServiceFile ) );
|
|
if ( target == userService )
|
|
{
|
|
service = ReplaceExactlyOnce( service, "ExecStart=/usr/bin/wayscriber --daemon", $"ExecStart={destination} --daemon", "service ExecStart" );
|
|
service = ReplaceExactlyOnce( service, "Environment=\"PATH=/usr/local/bin:/usr/bin:/bin\"", $"Environment=\"PATH={installDir}:/usr/local/bin:/usr/bin:/bin\"", "service PATH" );
|
|
}
|
|
using var temporary = new TemporaryDirectory( "wayscriber-service" );
|
|
var source = Path.Combine( temporary.Path, RepositoryNames.UserServiceFile );
|
|
Files.WriteAtomic( source, service );
|
|
await RunPossiblyRoot( context, NeedsPrivilege( Path.GetDirectoryName( target )! ), Programs.Install, [InstallArguments.DataFile, source, target] );
|
|
await context.Run( Programs.SystemControl, ["--user", "daemon-reload"] );
|
|
await context.Run( Programs.SystemControl, ["--user", "enable", RepositoryNames.UserServiceFile] );
|
|
var restart = await context.Run( Programs.SystemControl, ["--user", "restart", RepositoryNames.UserServiceFile], allowedExitCodes: new HashSet<int> { ExitCodes.Success, ExitCodes.Failure } );
|
|
if ( restart.ExitCode != ExitCodes.Success )
|
|
{
|
|
await context.Run( Programs.SystemControl, ["--user", "start", RepositoryNames.UserServiceFile] );
|
|
}
|
|
}
|
|
|
|
private static void ConfigureHyprland( string home, string destination, bool includeAutostart )
|
|
{
|
|
var path = Path.Combine( home, ".config/hypr/hyprland.conf" );
|
|
if ( !File.Exists( path ) )
|
|
{
|
|
throw new ToolException( $"Hyprland config not found: {path}" );
|
|
}
|
|
var text = Files.Read( path );
|
|
var additions = new List<string>( );
|
|
if ( includeAutostart && !text.Contains( "wayscriber --daemon", StringComparison.Ordinal ) && !text.Contains( $"{destination} --daemon", StringComparison.Ordinal ) )
|
|
{
|
|
additions.Add( $"exec-once = {destination} --daemon" );
|
|
}
|
|
if ( !text.Contains( "wayscriber --daemon-toggle", StringComparison.Ordinal ) && !text.Contains( "pkill -SIGUSR1 wayscriber", StringComparison.Ordinal ) )
|
|
{
|
|
additions.Add( $"bind = SUPER, D, exec, {destination} --daemon-toggle" );
|
|
}
|
|
if ( additions.Count > 0 )
|
|
{
|
|
Files.WriteAtomic( path, text.TrimEnd( ) + "\n\n# wayscriber\n" + string.Join( '\n', additions ) + "\n" );
|
|
}
|
|
}
|
|
|
|
private static bool HasConflictingExecStart( string path, string destination, IEnumerable<string> known )
|
|
{
|
|
if ( !File.Exists( path ) )
|
|
{
|
|
return false;
|
|
}
|
|
var lines = File.ReadLines( path ).Where( line => line.TrimStart( ).StartsWith( "ExecStart=", StringComparison.Ordinal ) ).ToArray( );
|
|
return lines.Length > 0 && !lines.Any( line => line.Contains( destination, StringComparison.Ordinal ) ) && known.Any( binary => lines.Any( line => line.Contains( binary, StringComparison.Ordinal ) ) );
|
|
}
|
|
|
|
private static bool SameFile( string left, string right )
|
|
{
|
|
if ( !File.Exists( left ) || !File.Exists( right ) )
|
|
{
|
|
return false;
|
|
}
|
|
return Path.GetFullPath( File.ResolveLinkTarget( left, true )?.FullName ?? left ) == Path.GetFullPath( File.ResolveLinkTarget( right, true )?.FullName ?? right );
|
|
}
|
|
|
|
private static async Task<bool> IsPackageOwned( ToolContext context, string path )
|
|
{
|
|
if ( !await CommandExists( context, Programs.Pacman ) )
|
|
{
|
|
return false;
|
|
}
|
|
var result = await context.Run( Programs.Pacman, ["-Qoq", path], capture: true, trace: false, allowedExitCodes: new HashSet<int> { ExitCodes.Success, ExitCodes.Failure } );
|
|
return result.ExitCode == ExitCodes.Success;
|
|
}
|
|
|
|
private static async Task<bool> CommandExists( ToolContext context, string command ) =>
|
|
(await context.Run( Programs.Which, [command], capture: true, trace: false, allowedExitCodes: new HashSet<int> { ExitCodes.Success, ExitCodes.Failure } )).ExitCode == ExitCodes.Success;
|
|
|
|
private static string ReplaceExactlyOnce( string text, string oldValue, string newValue, string label )
|
|
{
|
|
if ( text.Split( oldValue ).Length != 2 )
|
|
{
|
|
throw new ToolException( $"Expected exactly one {label}." );
|
|
}
|
|
return text.Replace( oldValue, newValue, StringComparison.Ordinal );
|
|
}
|
|
|
|
private static Task<ProcessResult> InstallData( ToolContext context, string source, string destination ) =>
|
|
RunPossiblyRoot( context, NeedsPrivilege( Path.GetDirectoryName( destination )! ), Programs.Install, [InstallArguments.DataFile, source, destination] );
|
|
|
|
internal static string NormalizeBinDirectory( string value )
|
|
{
|
|
var path = Path.GetFullPath( Path.TrimEndingDirectorySeparator( value ) );
|
|
return path switch
|
|
{
|
|
"/usr" => "/usr/bin",
|
|
"/usr/local" => "/usr/local/bin",
|
|
_ => path
|
|
};
|
|
}
|
|
|
|
private static bool NeedsPrivilege( string path )
|
|
{
|
|
var current = Path.GetFullPath( path );
|
|
while ( !Directory.Exists( current ) && Path.GetDirectoryName( current ) is { } parent && parent != current )
|
|
{
|
|
current = parent;
|
|
}
|
|
try
|
|
{
|
|
using var stream = new FileStream( Path.Combine( current, $".wayscriber-write-{Guid.NewGuid( ):N}" ), FileMode.CreateNew, FileAccess.Write, FileShare.None, 1, FileOptions.DeleteOnClose );
|
|
return false;
|
|
}
|
|
catch ( UnauthorizedAccessException ) { return true; }
|
|
}
|
|
|
|
internal static bool ShouldUseSudo( bool requiresPrivilege, uint effectiveUserId ) =>
|
|
requiresPrivilege && effectiveUserId != RootUserId;
|
|
|
|
[System.Runtime.InteropServices.DllImport( "libc", EntryPoint = "geteuid" )]
|
|
private static extern uint GetEffectiveUserId( );
|
|
|
|
private static Task<ProcessResult> RunPossiblyRoot( ToolContext context, bool requiresPrivilege, string program, IReadOnlyList<string> arguments,
|
|
IReadOnlySet<int>? allowedExitCodes = null ) => ShouldUseSudo( requiresPrivilege, GetEffectiveUserId( ) )
|
|
? context.Run( Programs.Sudo, [program, .. arguments], allowedExitCodes: allowedExitCodes )
|
|
: context.Run( program, arguments, allowedExitCodes: allowedExitCodes );
|
|
}
|