Skip to content

Commit e46c4f8

Browse files
[Samples] Provide a file system watcher sample. (#55)
Add two different samples that show how to perform a file system watcher using two channels and a watcher, either using the FSEvents API of the managed FilySystemWatch. We need to split the samples so that we could use a macos bot in the GitHub actions.
1 parent eea6250 commit e46c4f8

27 files changed

+1384
-1
lines changed

.github/workflows/build.yml

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ on:
1414

1515
jobs:
1616
build:
17+
name: Build library
1718

1819
runs-on: ubuntu-latest
1920

@@ -44,3 +45,50 @@ jobs:
4445
path: TestResults
4546
# Use always() to always run this step to publish test results when there are test failures
4647
if: ${{ always() }}
48+
49+
build-samples:
50+
name: Build Samples
51+
52+
runs-on: ubuntu-latest
53+
54+
steps:
55+
- uses: actions/checkout@v4
56+
57+
- name: Setup .NET
58+
uses: actions/setup-dotnet@v4
59+
with:
60+
dotnet-version: 8.0.x
61+
62+
- name: Restore dependencies
63+
run: dotnet restore samples.sln
64+
working-directory: ./samples
65+
66+
- name: Build
67+
run: dotnet build --no-restore samples.sln
68+
working-directory: ./samples
69+
70+
build-samples-osx:
71+
name: Build OS X Samples
72+
73+
runs-on: macos-latest
74+
75+
steps:
76+
- uses: actions/checkout@v4
77+
78+
- name: Setup .NET
79+
uses: actions/setup-dotnet@v4
80+
with:
81+
dotnet-version: 8.0.x
82+
83+
- name: Restore workloads
84+
run: sudo dotnet workload restore samples-osx.sln
85+
working-directory: ./samples
86+
87+
- name: Restore dependencies
88+
run: dotnet restore samples-osx.sln
89+
working-directory: ./samples
90+
91+
92+
- name: Build
93+
run: dotnet build --no-restore samples-osx.sln
94+
working-directory: ./samples

.github/workflows/codeql.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ jobs:
2727
# - https://gh.io/supported-runners-and-hardware-resources
2828
# - https://gh.io/using-larger-runners (GitHub.com only)
2929
# Consider using larger runners or machines with greater resources for possible analysis time improvements.
30-
runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }}
30+
runs-on: 'macos-latest'
3131
permissions:
3232
# required for all workflows
3333
security-events: write
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
<?xml version="1.0" encoding="UTF-8" ?>
2+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3+
<plist version="1.0">
4+
<dict>
5+
</dict>
6+
</plist>

samples/FSEvents/FSEvents.csproj

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
<?xml version="1.0" encoding="utf-8"?>
2+
<Project Sdk="Microsoft.NET.Sdk">
3+
<PropertyGroup>
4+
<TargetFramework>net8.0-macos</TargetFramework>
5+
<OutputType>Exe</OutputType>
6+
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
7+
<ImplicitUsings>true</ImplicitUsings>
8+
<SupportedOSPlatformVersion>13.0</SupportedOSPlatformVersion>
9+
<RootNamespace>FSEvents</RootNamespace>
10+
<AssemblyName>fsevents</AssemblyName>
11+
<RuntimeIdentifiers>osx-x64;osx-arm64</RuntimeIdentifiers>
12+
<LinkMode>Full</LinkMode>
13+
<AssemblyCompany>themacaque.com</AssemblyCompany>
14+
<AssemblyCopyright>Manuel de la Peña Saenz</AssemblyCopyright>
15+
<DebugSymbols>true</DebugSymbols>
16+
<DebugType>full</DebugType>
17+
<Nullable>enable</Nullable>
18+
<ApplicationId>com.marille.FSEvents</ApplicationId>
19+
</PropertyGroup>
20+
21+
<ItemGroup>
22+
<ProjectReference Include="..\..\src\Marille\Marille.csproj" />
23+
<ProjectReference Include="..\Marille.FileSystem\Marille.FileSystem.csproj" />
24+
</ItemGroup>
25+
26+
<ItemGroup>
27+
<PackageReference Include="Mono.Options" Version="6.12.0.148" />
28+
<PackageReference Include="Serilog" Version="4.0.2-dev-02226" />
29+
<PackageReference Include="Serilog.Enrichers.Thread" Version="3.2.0-dev-00756" />
30+
<PackageReference Include="Serilog.Sinks.Console" Version="5.0.1" />
31+
<PackageReference Include="Serilog.Sinks.Debug" Version="2.0.0" />
32+
<PackageReference Include="UuidExtensions" Version="1.2.0" />
33+
</ItemGroup>
34+
</Project>

samples/FSEvents/FSMonitor.cs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
using CoreServices;
2+
using Marille;
3+
4+
namespace FSEvents;
5+
6+
sealed class FSMonitor : FSEventStream {
7+
readonly Hub _hub;
8+
9+
public FSMonitor (List<string> rootPaths, Hub hub, FSEventStreamCreateFlags createFlags)
10+
: base (rootPaths.ToArray (), TimeSpan.Zero, createFlags)
11+
{
12+
// keep a reference to the hub so that we can post the messages to it
13+
_hub = hub;
14+
}
15+
16+
protected override void OnEvents (FSEvent [] events)
17+
{
18+
foreach (var evnt in events) {
19+
// publish to the hub the event, the workers will take care of it from different threads
20+
_hub.TryPublish (nameof (FSMonitor), evnt);
21+
}
22+
}
23+
}

samples/FSEvents/Info.plist

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3+
<plist version="1.0">
4+
<dict>
5+
<key>CFBundleName</key>
6+
<string>Marille.FSEvents</string>
7+
<key>CFBundleIdentifier</key>
8+
<string>com.marille.FSEvents</string>
9+
<key>CFBundleShortVersionString</key>
10+
<string>1.0</string>
11+
<key>CFBundleVersion</key>
12+
<string>1</string>
13+
<key>LSMinimumSystemVersion</key>
14+
<string>13.0</string>
15+
<key>CFBundleDevelopmentRegion</key>
16+
<string>en</string>
17+
<key>CFBundleInfoDictionaryVersion</key>
18+
<string>6.0</string>
19+
<key>CFBundlePackageType</key>
20+
<string>APPL</string>
21+
<key>CFBundleSignature</key>
22+
<string>????</string>
23+
<key>NSHumanReadableCopyright</key>
24+
<string>themacaque.com</string>
25+
<key>NSPrincipalClass</key>
26+
<string>NSApplication</string>
27+
<key>NSMainStoryboardFile</key>
28+
<string>Main</string>
29+
<key>LSUIElement</key>
30+
<string>1</string>
31+
</dict>
32+
</plist>

samples/FSEvents/Main.cs

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
using System.Runtime.InteropServices;
2+
using System.Text;
3+
using CoreServices;
4+
using FSEvents.Workers;
5+
using Marille;
6+
using Marille.FileSystem;
7+
using Marille.FileSystem.Workers;
8+
using Mono.Options;
9+
using ObjCRuntime;
10+
using Serilog;
11+
12+
namespace FSEvents;
13+
14+
static class MainClass {
15+
#region Fields
16+
17+
public static int Verbosity { get; set; } = 1;
18+
public static bool ShowHelp { get; set; } = false;
19+
20+
public static List<string> Paths { get; } = new ();
21+
22+
#endregion
23+
24+
#region Helpers
25+
26+
static void PrintHelp (OptionSet options)
27+
{
28+
Console.WriteLine ("Usage: fsevents [OPTIONS]+ message");
29+
Console.WriteLine ("Example of a file system watcher using Marille.");
30+
Console.WriteLine ();
31+
Console.WriteLine ("Options:");
32+
options.WriteOptionDescriptions (Console.Out);
33+
}
34+
35+
static void InitializeLog ()
36+
{
37+
LoggerConfiguration logConfiguration = new LoggerConfiguration ()
38+
.Enrich.WithThreadId ()
39+
.Enrich.WithThreadName ();
40+
41+
// cast verbosity to a log level and set it as the minimum level
42+
var minLevel = (LogLevel) Verbosity;
43+
44+
switch (minLevel) {
45+
case LogLevel.Fatal:
46+
logConfiguration.MinimumLevel.Fatal ();
47+
break;
48+
case LogLevel.Error:
49+
logConfiguration.MinimumLevel.Error ();
50+
break;
51+
case LogLevel.Information:
52+
logConfiguration.MinimumLevel.Information ();
53+
break;
54+
case LogLevel.Debug:
55+
logConfiguration.MinimumLevel.Debug ();
56+
break;
57+
default:
58+
logConfiguration.MinimumLevel.Information ();
59+
break;
60+
}
61+
62+
logConfiguration.MinimumLevel.Debug ();
63+
64+
// thread id == min level of log
65+
Log.Logger = logConfiguration
66+
.WriteTo.Console (outputTemplate: "{Timestamp:HH:mm:ss} [{Level}] ({ThreadId}) {Message}{NewLine}{Exception}")
67+
.CreateLogger ();
68+
}
69+
70+
#endregion
71+
72+
static int Main (string [] args)
73+
{
74+
try {
75+
Console.OutputEncoding = new UTF8Encoding (false, false);
76+
NSApplication.Init ();
77+
Environment.CurrentDirectory = Runtime.OriginalWorkingDirectory!;
78+
return Main2 (args);
79+
} catch (Exception e) {
80+
Console.WriteLine (e);
81+
Exit (1);
82+
}
83+
return 1;
84+
}
85+
86+
[DllImport ("/usr/lib/libSystem.dylib")]
87+
static extern void exit (int exitcode);
88+
public static void Exit (int exitCode = 0)
89+
{
90+
// This is ugly. *Very* ugly. The problem is that:
91+
//
92+
// * The Apple API we use will create background threads to process messages.
93+
// * Those messages are delivered to managed code, which means the mono runtime
94+
// starts tracking those threads.
95+
// * The mono runtime will not terminate those threads (in fact the mono runtime
96+
// will do nothing at all to those threads, since they're not running managed
97+
// code) upon shutdown. But the mono runtime will wait for those threads to
98+
// exit before exiting the process. This means mtouch will never exit.
99+
//
100+
// So just go the nuclear route.
101+
exit (exitCode);
102+
}
103+
104+
static int Main2 (string [] args)
105+
{
106+
// Use mono.options to parse the args, this is a sample so we won't do too complex things
107+
var os = new OptionSet () {
108+
{ "h|?|help", "Displays the help", v => ShowHelp = v != null },
109+
{ "v", "Verbose", v => Verbosity++ },
110+
{ "q", "Quiet", v => Verbosity = 0 },
111+
{ "p|path=", "Add a path to monitor", v => Paths.Add (v) },
112+
};
113+
114+
try {
115+
var extra = os.Parse (args);
116+
} catch (Exception e) {
117+
// We could not parse the argumets, print the error and suggest to call help
118+
Console.WriteLine("fsevents:");
119+
Console.WriteLine (e.Message);
120+
Console.WriteLine ("Try `fsevents --help' for more information.");
121+
return 1;
122+
}
123+
124+
if (ShowHelp) {
125+
PrintHelp (os);
126+
return 0;
127+
}
128+
129+
// Add logging so that we can see what is going on
130+
InitializeLog ();
131+
132+
// create the app data dir in which we will store the diffs
133+
var baseDir = Path.Combine (Environment.GetFolderPath (Environment.SpecialFolder.ApplicationData), "Marille", "Diffs");
134+
Directory.CreateDirectory (baseDir);
135+
Log.Information ("Created base directory {BaseDir}", baseDir);
136+
137+
// we are going to create a uuid7 for the new snapshot. uuid7 is time shortable and unique so that way
138+
// we will be able to read see the snapshot and the diff in the future.
139+
var snapshot = SnapshotManager.Create (baseDir, Paths);
140+
141+
Log.Information ("Starting fsevents with {Paths}", Paths);
142+
143+
// we need to create several things to be able to get the events:
144+
// 1. Hub: will be used to deliver the events to the consumers.
145+
// 2. Worker: will be used to process the events.
146+
// 3. FSMonitor: will be used to monitor the file system and deliver the events to the hub.
147+
var hub = new Hub ();
148+
149+
// because we are going to start a main loop in the main thread, we need the channel initialization to be
150+
// done in a background thread else we will be blocked.
151+
Task.Run (async () => {
152+
// workers will be disposed by the hub
153+
//var worker = new LogEventToConsole ();
154+
var eventFilter = new FSEventFilterer (hub);
155+
var diffGenerator = new DiffGenerator (snapshot);
156+
157+
var fsEventsErrorHandler = new FSEventsErrorHandler ();
158+
var textFileChangedErrorHandler = new TextFileChangedErrorHandler ();
159+
160+
var fsEventsConfig = new TopicConfiguration {
161+
Mode = ChannelDeliveryMode.AtLeastOnceSync
162+
};
163+
var txtEventsConfig = new TopicConfiguration {
164+
Mode = ChannelDeliveryMode.AtMostOnceAsync
165+
};
166+
await hub.CreateAsync (nameof (FSMonitor), txtEventsConfig, textFileChangedErrorHandler, diffGenerator);
167+
await hub.CreateAsync (nameof (FSMonitor), fsEventsConfig, fsEventsErrorHandler, eventFilter);
168+
Log.Information ("Channels created");
169+
Console.WriteLine ("Feel free to edit your files, we will keep a history of the edits in the session!!!");
170+
Console.WriteLine ("Press Ctrl+C to stop the watcher.");
171+
});
172+
173+
var monitor = new FSMonitor (Paths, hub, FSEventStreamCreateFlags.FileEvents | FSEventStreamCreateFlags.WatchRoot);
174+
monitor.ScheduleWithRunLoop (NSRunLoop.Current);
175+
Log.Information ("FSEvent monitor scheduled");
176+
monitor.Start ();
177+
Console.WriteLine($"Starting watch on {string.Join (',', Paths)}");
178+
NSRunLoop.Main.Run ();
179+
180+
return 0;
181+
}
182+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
using CoreServices;
2+
using Marille.FileSystem.Events;
3+
using Serilog;
4+
5+
namespace FSEvents;
6+
7+
/// <summary>
8+
///
9+
/// </summary>
10+
public static class TextFileChangedEventFactory {
11+
12+
public static TextFileChangedEvent? FromFSEvent (FSEvent rawEvent)
13+
{
14+
// we can have ore than one flag so we need to be smart about it
15+
var flag = TextFileChangedType.Unknown;
16+
17+
if (rawEvent.Flags.HasFlag (FSEventStreamEventFlags.ItemRemoved)) {
18+
flag = TextFileChangedType.ItemRemoved;
19+
} else if (rawEvent.Flags.HasFlag (FSEventStreamEventFlags.ItemCreated)) {
20+
// we can have a rename! so we need to check for that
21+
flag = TextFileChangedType.ItemCreated;
22+
if (rawEvent.Flags.HasFlag (FSEventStreamEventFlags.ItemRenamed)) {
23+
flag = TextFileChangedType.ItemRenamed;
24+
}
25+
} else if (rawEvent.Flags.HasFlag (FSEventStreamEventFlags.ItemRenamed)) {
26+
flag = TextFileChangedType.ItemRenamed;
27+
} else if (rawEvent.Flags.HasFlag (FSEventStreamEventFlags.ItemModified)) {
28+
// we can have a new file that was created and modified or just a modified file
29+
flag = rawEvent.Flags.HasFlag (FSEventStreamEventFlags.ItemCreated)
30+
? TextFileChangedType.ItemCreated
31+
: TextFileChangedType.ItemModified;
32+
}
33+
34+
Log.Debug ("Returning flag {Flag} for {Flags}", flag, rawEvent.Flags);
35+
36+
return (flag == TextFileChangedType.Unknown) ? null : new(rawEvent.Path!, flag);
37+
}
38+
39+
public static TextFileChangedEvent FromFSEvent (FSEvent fromRawEvent, FSEvent toRawEvent) =>
40+
new (fromRawEvent.Path!, toRawEvent.Path!);
41+
}

0 commit comments

Comments
 (0)