Skip to content

Commit

Permalink
Release v1.1.0
Browse files Browse the repository at this point in the history
Merge pull request #5 from TehGM/dev
  • Loading branch information
TehGM authored Apr 4, 2021
2 parents cccbf6f + f67af7d commit 27f29d7
Show file tree
Hide file tree
Showing 14 changed files with 174 additions and 49 deletions.
23 changes: 20 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# fsriev - a file watch utility
[![GitHub release (latest SemVer)](https://img.shields.io/github/v/release/TehGM/fsriev?include_prereleases)](https://github.com/TehGM/fsriev/releases)[![GitHub top language](https://img.shields.io/github/languages/top/TehGM/fsriev)](https://github.com/TehGM/fsriev) [![GitHub](https://img.shields.io/github/license/TehGM/fsriev)](LICENSE) [![GitHub Workflow Status](https://img.shields.io/github/workflow/status/TehGM/fsriev/.NET%20Build)](https://github.com/TehGM/fsriev/actions) [![GitHub issues](https://img.shields.io/github/issues/TehGM/fsriev)](https://github.com/TehGM/fsriev/issues)
[![GitHub release (latest SemVer)](https://img.shields.io/github/v/release/TehGM/fsriev?include_prereleases)](https://github.com/TehGM/fsriev/releases) [![GitHub top language](https://img.shields.io/github/languages/top/TehGM/fsriev)](https://github.com/TehGM/fsriev) [![GitHub](https://img.shields.io/github/license/TehGM/fsriev)](LICENSE) [![GitHub Workflow Status](https://img.shields.io/github/workflow/status/TehGM/fsriev/.NET%20Build)](https://github.com/TehGM/fsriev/actions) [![GitHub issues](https://img.shields.io/github/issues/TehGM/fsriev)](https://github.com/TehGM/fsriev/issues)

fsriev is a simple but highly customizable file watcher for Windows and Linux.

Expand All @@ -11,7 +11,9 @@ See [releases](https://github.com/TehGM/fsriev/releases/) to download the latest
Alternatively, [build](#Building) the project yourself.

### Configuration
The primary means of configuring fsriev is through [appsettings.json](fsriev/appsettings.json) file. Each directory to watch needs to be added as a new JSON object to `Watchers` array.
The primary means of configuring fsriev is through [appsettings.json](fsriev/appsettings.json) file.
#### Workers Config
Each directory to watch needs to be added as a new JSON object to `Watchers` array.

Property Name | Type | Required? | Default Value | Description
:------------:|:------:|:---------:|:---------------:|------------
Expand All @@ -24,16 +26,31 @@ SkipWhenBusy | bool | No | true | Watcher might receive multiple events at once.
NotifyFilters | string/int | No | LastWrite,FileName | Flags that will be checked to determine if the file has changed. See [NotifyFilters](https://docs.microsoft.com/en-gb/dotnet/api/system.io.notifyfilters?view=net-5.0) for a list of valid values.
Exclusions | array of strings | No | | Filters of ignored files. Useful for example when you want to ignore VS temporary files (`*~*.tmp`) or minified JS outputs (`*.min.*`).
WorkingDirectory | string | No | Value of `FolderPath` | Working directory that will be used when executing the commands.
ShowCommandOutput | bool | No | true | Whether output of ran commands should be displayed.
Commands | array of strings | No | | Commands to execute when a file change has been detected. Commands are executed in order, regardless if previous command executed correctly or not. *Note: if no command is added, a warning will be output to logs.*

#### Other Config
There are a few properties that can be configured ***outside*** of `Watchers` array.

Property Name | Type | Required? | Default Value | Description
:------------:|:------:|:---------:|:---------------:|------------
CommandOutputMode | string/int | No | Console | How command output should be displayed. Supported values are `Console`, `Log`. If you run fsriev manually, in terminal etc, "Console" is recommended. If you depend on log files to see the output, use "Log". *Note: you can technically use both at once (`Console,Log`), but it is not recommended. This might be useful if logging to console is disabled in [Logging configuration](#logging).*
CommandOutputLevel | string/int | No | Information | *Only applicable when `CommandOutputMode` is set to "Log".* Sets the level that command output will be logged as. Valid values are "Verbose", "Debug", "Information", "Warning", "Error" and "Fatal". *Note: this applies only to STDOUT. STDERR will always be logged as Error.*

#### Logging
By default the application will log to terminal window and to `%PROGRAMDATA%/TehGM/fsriev/logs`.

Logging configuration is done via [logsettings.json](fsriev/logsettings.json). See [Serilog.Settings.Configuration](https://github.com/serilog/serilog-settings-configuration) for more info.

Errors that occur when application is loading the configuration will be logged to `%PROGRAMDATA%/TehGM/fsriev/logs`.

> Note: On Windows `%PROGRAMDATA%` will most likely be `C:\ProgramData`, while on linux it'll most likely be `/mnt/share`.
> Note: On Windows `%PROGRAMDATA%` will most likely be `C:\ProgramData`.
> Currently `%PROGRAMDATA%` appears to be broken when used in [logsettings.json](fsriev/logsettings.json) on Linux, due to a likely bug in Serilog configuration library. Refer to [this issue](https://github.com/serilog/serilog-settings-configuration/issues/257) for more info.
### Important Notes
- Do **NOT** close fsriev by pressing X if any of the commands is still running. Due to terminal limitations, fsriev will not have any chance to kill command process.
Instead, send shut down signal to fsriev - for example by pressing `Ctrl+C`. Doing so will notify fsriev to kill commands before exiting.
- Currently, the default application config contains example configuration. It will most likely log an error due to directory not existing. Simply update your configuration to solve this.

## Building
1. Install [.NET 5 SDK](https://dotnet.microsoft.com/download/dotnet/5.0).
Expand Down
9 changes: 9 additions & 0 deletions fsriev/Entities/ApplicationOptions.cs
Original file line number Diff line number Diff line change
@@ -1,9 +1,18 @@
using System.Collections.Generic;
using Microsoft.Extensions.Logging;

namespace TehGM.Fsriev
{
public class ApplicationOptions
{
/// <summary>Output mode to use for command output.</summary>
public CommandOutputMode CommandOutputMode { get; set; } = CommandOutputMode.Console;
/// <summary>Log level used for output.</summary>
/// <remarks><para>Only applicable when <see cref="CommandOutputMode"/> is set to <see cref="CommandOutputMode.Log"/>.</para>
/// <para>This affects only normal output. STDERR output will always be logged as <see cref="LogLevel.Error"/>.</para></remarks>
public LogLevel CommandOutputLevel { get; set; } = LogLevel.Information;

/// <summary>Configs for all watchers.</summary>
public IEnumerable<WatcherOptions> Watchers { get; set; }
}
}
10 changes: 10 additions & 0 deletions fsriev/Entities/CommandOutputMode.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
namespace TehGM.Fsriev
{
public enum CommandOutputMode
{
/// <summary>Show command output using <see cref="System.Console"/> class.</summary>
Console = 1 << 0,
/// <summary>Show command output using the logging library.</summary>
Log = 1 << 1
}
}
2 changes: 2 additions & 0 deletions fsriev/Entities/WatcherOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,5 +37,7 @@ public class WatcherOptions
public NotifyFilters NotifyFilters { get; set; } = NotifyFilters.LastWrite | NotifyFilters.FileName;
/// <summary>Patterns that will cause the change to be skipped.</summary>
public IEnumerable<string> Exclusions { get; set; }
/// <summary>Whether command output will be displayed.</summary>
public bool ShowCommandOutput { get; set; } = true;
}
}
11 changes: 11 additions & 0 deletions fsriev/Extensions/TerminalExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,17 @@ namespace TehGM.Fsriev
{
public static class TerminalExtensions
{
public static Process Create(this ITerminal terminal, string command, string workingDirectory)
=> terminal.Create(command, workingDirectory, false);
public static Process Create(this ITerminal terminal, string command)
=> Create(terminal, command, null);

public static Process Execute(this ITerminal terminal, string command, string workingDirectory, bool asRoot)
{
Process prc = terminal.Create(command, workingDirectory, asRoot);
prc.Start();
return prc;
}
public static Process Execute(this ITerminal terminal, string command, string workingDirectory)
=> terminal.Execute(command, workingDirectory, false);
public static Process Execute(this ITerminal terminal, string command)
Expand Down
20 changes: 20 additions & 0 deletions fsriev/Extensions/WatcherDependencyInjectionExtensions.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System;
using System.Runtime.InteropServices;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Options;
using TehGM.Fsriev;
Expand Down Expand Up @@ -32,10 +33,29 @@ public void PostConfigure(string name, ApplicationOptions options)
{
foreach (WatcherOptions watcher in options.Watchers)
{
// normalize paths
watcher.FolderPath = NormalizePath(watcher.FolderPath);
watcher.WorkingDirectory = NormalizePath(watcher.WorkingDirectory);

// verify used filters
if (watcher.FileFilters == null)
watcher.FileFilters = _defaultFileFilters;
}
}

private static string NormalizePath(string path)
{
if (path == null)
return null;

if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
return path.Replace('/', '\\');
if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
return path.Replace('\\', '/');

// fallback: just return unchanged
return path;
}
}
}
}
6 changes: 3 additions & 3 deletions fsriev/ITerminal.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,11 @@ namespace TehGM.Fsriev
{
public interface ITerminal
{
/// <summary>Starts a terminal process and executes the <paramref name="command"/> and returns the process handle.</summary>
/// <param name="command">Command to execute.</param>
/// <summary>Creates a terminal process for command <paramref name="command"/> and returns the process handle.</summary>
/// <param name="command">Command to create.</param>
/// <param name="asRoot">Should the terminal start as root user? If false, will execute as current user.</param>
/// <remarks>Current implementation always runs as current user on Windows.</remarks>
/// <returns>Started process.</returns>
Process Execute(string command, string workingDirectory, bool asRoot);
Process Create(string command, string workingDirectory, bool asRoot);
}
}
3 changes: 0 additions & 3 deletions fsriev/Logging/LoggingInitializationExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,6 @@ public static void ConfigureSerilog(HostBuilderContext context, LoggerConfigurat

public static void EnableUnhandledExceptionLogging()
{
if (Log.Logger != null)
return;

// add default logger for errors that happen before host runs
string dir = Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData);
Log.Logger = new LoggerConfiguration()
Expand Down
7 changes: 2 additions & 5 deletions fsriev/Services/Terminal.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ public Terminal(ILogger<Terminal> logger)
}

/// <inheritdoc/>
public Process Execute(string command, string workingDirectory, bool asRoot)
public Process Create(string command, string workingDirectory, bool asRoot)
{
// create process, start it and immediately return
_log.LogDebug("Creating process: {Command}", command);
Expand All @@ -39,14 +39,11 @@ public Process Execute(string command, string workingDirectory, bool asRoot)
prc.StartInfo.Verb = "runas";
}
else
throw new PlatformNotSupportedException($"{nameof(Execute)} is only supported on Windows and Linux platforms.");
throw new PlatformNotSupportedException($"{nameof(Terminal)} is only supported on Windows and Linux platforms.");

prc.StartInfo.WorkingDirectory = string.IsNullOrWhiteSpace(workingDirectory) ? Environment.CurrentDirectory : workingDirectory;
prc.StartInfo.UseShellExecute = false;
prc.StartInfo.CreateNoWindow = true;

_log.LogTrace("Starting the process");
prc.Start();
return prc;
}
}
Expand Down
106 changes: 78 additions & 28 deletions fsriev/Services/Watcher.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,23 +14,30 @@ public class Watcher : IDisposable
public string Name { get; }
public bool IsBusy { get; private set; }

// options and services
private readonly WatcherOptions _options;
private readonly ApplicationOptions _applicationOptions;
private readonly FileSystemWatcher _watch;
private readonly IEnumerable<string> _commands;
private readonly ITerminal _terminal;
private readonly WatcherOptions _options;
private readonly ILogger _log;
// calculated options cache
private readonly IEnumerable<ExclusionFilter> _exclusions;
private readonly string _workingDirectory;
// flow control
private bool _disposed;
private readonly SemaphoreSlim _lock = new SemaphoreSlim(1, 1);
private readonly CancellationTokenSource _cts = new CancellationTokenSource();
private readonly object _outputLock = new object();

public Watcher(WatcherOptions options, ITerminal terminal, ILogger log)
public Watcher(WatcherOptions options, ApplicationOptions applicationOptions, ITerminal terminal, ILogger log)
{
if (string.IsNullOrWhiteSpace(options.FolderPath))
throw new ArgumentNullException(nameof(options.FolderPath));

this._log = log;
this._options = options;
this._applicationOptions = applicationOptions;
this._terminal = terminal;
this.Name = options.GetName();

Expand All @@ -42,6 +49,9 @@ public Watcher(WatcherOptions options, ITerminal terminal, ILogger log)
this._log.LogTrace("Watcher {Watcher}: building exclusion filters", this.Name);
this._exclusions = options.Exclusions?.Select(f => ExclusionFilter.Build(f)) ?? Enumerable.Empty<ExclusionFilter>();

this._log.LogTrace("Watcher {Watcher}: determining working directory", this.Name);
this._workingDirectory = string.IsNullOrWhiteSpace(this._options.WorkingDirectory) ? this._options.FolderPath : this._options.WorkingDirectory;

this._log.LogTrace("Watcher {Watcher}: creating {Type}", this.Name, typeof(FileSystemWatcher).Name);
this._watch = new FileSystemWatcher(options.FolderPath);
this._watch.NotifyFilter = options.NotifyFilters;
Expand Down Expand Up @@ -95,33 +105,9 @@ private async Task OnFileChangedAsync(object sender, FileSystemEventArgs e)
{
if (this._commands.Any())
{
this._log.LogInformation("Watch {Watcher}: File {File} changed, running commands");
this._log.LogInformation("Watcher {Watcher}: File {File} changed, running commands");
foreach (string cmd in this._options.Commands)
{
string workingDir = string.IsNullOrWhiteSpace(this._options.WorkingDirectory)
? this._options.FolderPath : this._options.WorkingDirectory;

// execute and wait
using Process prc = this._terminal.Execute(cmd, workingDir);
try
{
await prc.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
if (prc.ExitCode != 0)
this._log.LogError("Watcher {Watcher}: Process exited with error code {Code}", this.Name, prc.ExitCode);
else
this._log.LogTrace("Watcher {Watcher}: Done executing process");
}
catch (OperationCanceledException)
{
// if didn't exist, but watcher is disposing, then force kill the process
if (!prc.HasExited)
{
this._log.LogDebug("Watcher {Watcher}: Force killing process {Process}", this.Name, cmd);
prc.Kill(true);
}
throw;
}
}
await this.RunProcessAsync(cmd, cancellationToken);
this._log.LogInformation("Watcher {Watcher}: Done executing commands");
}
else
Expand All @@ -140,6 +126,70 @@ private async Task OnFileChangedAsync(object sender, FileSystemEventArgs e)
}
}

private async Task RunProcessAsync(string command, CancellationToken cancellationToken)
{
// execute and wait
using Process prc = this._terminal.Create(command, this._workingDirectory);

if (this._options.ShowCommandOutput)
{
prc.StartInfo.RedirectStandardOutput = true;
prc.StartInfo.RedirectStandardError = true;
prc.OutputDataReceived += (sender, e) => HandleProcessOutput(e.Data, false);
prc.ErrorDataReceived += (sender, e) => HandleProcessOutput(e.Data, true);
}

try
{
prc.Start();
if (this._options.ShowCommandOutput)
{
prc.BeginOutputReadLine();
prc.BeginErrorReadLine();
}
await prc.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
if (prc.ExitCode != 0)
this._log.LogError("Watcher {Watcher}: Process exited with error code {Code}", this.Name, prc.ExitCode);
else
this._log.LogTrace("Watcher {Watcher}: Done executing process");
}
catch (OperationCanceledException)
{
// if didn't exit, but watcher is disposing, then force kill the process
if (!prc.HasExited)
{
this._log.LogDebug("Watcher {Watcher}: Force killing process {Process}", this.Name, command);
prc.Kill(true);
}
throw;
}
}

private void HandleProcessOutput(string output, bool isError)
{
if (output == null)
return;

lock (_outputLock)
{
CommandOutputMode mode = this._applicationOptions.CommandOutputMode;
if ((mode & CommandOutputMode.Console) == CommandOutputMode.Console)
{
ConsoleColor previousColor = Console.ForegroundColor;
if (isError)
Console.ForegroundColor = ConsoleColor.DarkRed;
Console.WriteLine(output);
if (isError)
Console.ForegroundColor = previousColor;
}
if ((mode & CommandOutputMode.Log) == CommandOutputMode.Log)
{
LogLevel level = isError ? LogLevel.Error : this._applicationOptions.CommandOutputLevel;
this._log.Log(level, output);
}
}
}

public void Start()
{
this._log.LogDebug("Starting watcher {Watcher}", this.Name);
Expand Down
Loading

0 comments on commit 27f29d7

Please sign in to comment.