Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion Canopy.Core/Canopy.Core.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,19 @@
<ItemGroup>
<PackageReference Include="Faster.Map" Version="8.1.1" />
<PackageReference Include="JetBrains.Annotations" Version="2025.2.4" />
<PackageReference Include="OpenMeteoApi" Version="1.3.0" />
<PackageReference Include="Serilog" Version="4.3.2-dev-02433" />
<PackageReference Include="Serilog.Sinks.SpectreConsole" Version="0.3.3" />
<PackageReference Include="Silk.NET.OpenGL" Version="2.23.0" />
<PackageReference Include="SolarCalculator" Version="3.6.1" />
<PackageReference Include="StbImageSharp" Version="2.30.15" />
<PackageReference Include="SynesthesiaUtils" Version="2026.614.0" />
<PackageReference Include="SynesthesiaDev.Codon.Codec" Version="2026.730.0" />
<PackageReference Include="SynesthesiaDev.Synx.Codon" Version="2026.804.0" />
<PackageReference Include="SynesthesiaUtils" Version="2026.804.0" />
</ItemGroup>

<ItemGroup>
<Folder Include="Extensions\" />
</ItemGroup>

</Project>
259 changes: 259 additions & 0 deletions Canopy.Core/Canopy.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,259 @@
// Copyright (c) 2026 SynesthesiaDev <synesthesiadev@proton.me>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.

using Canopy.Configuration;
using Canopy.Providers;
using Canopy.Server;
using Canopy.Server.Messages;
using Serilog;
using Synesthesia.Utils.Extensions;
using SynesthesiaDev.Synx;
using SynesthesiaDev.Synx.Codon;

namespace Canopy;

public class Canopy(ICanopyPlatform platform)
{
public readonly ICanopyPlatform Platform = platform;

public static Config CurrentConfig = null!;

public static readonly GeopositionProvider GEOPOSITION_PROVIDER = new GeopositionProvider();
public static readonly WeatherProvider WEATHER_PROVIDER = new WeatherProvider();
public static readonly TimeOfDayProvider TIME_OF_DAY_PROVIDER = new TimeOfDayProvider();
public static readonly SeasonProvider SEASON_PROVIDER = new SeasonProvider();
public static readonly HolidayProvider HOLIDAY_PROVIDER = new HolidayProvider();

private CanopyState? lastState;

#if DEBUG
public static readonly string CANOPY_FOLDER_PATH = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
".canopy-development"
);
#else
public static readonly string CANOPY_FOLDER_PATH = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
".canopy"
);
#endif

public static bool ConfigMigrated = false;
public static readonly string CONFIG_FILE_PATH = Path.Combine(CANOPY_FOLDER_PATH, "config.synx");

public CanopyWebsocketServer? WebsocketServer;

public void Initialize()
{
Log.Verbose("Initializing Canopy..");

LoadRefreshable();

var task = Task.Run(async () =>
{
using var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(CurrentConfig.General.RefreshPeriod));
while (await timer.WaitForNextTickAsync())
Refresh();
});

Platform.InitializeTray(this);

Console.ReadLine();
}

public void LoadRefreshable()
{
loadConfig();
lastState = null;
GEOPOSITION_PROVIDER.InvalidateCache();

WebsocketServer?.Stop();
if (CurrentConfig.Websocket.Enabled)
{
WebsocketServer = new CanopyWebsocketServer();
WebsocketServer.Initialize();
}

Refresh();
}

private void loadConfig()
{
Log.Verbose("Loading config..");
if (!Directory.Exists(CANOPY_FOLDER_PATH))
{
Directory.CreateDirectory(CANOPY_FOLDER_PATH);
}

if (!File.Exists(CONFIG_FILE_PATH))
{
Log.Verbose("Config file doesn't exist.. creating new one");

File.Create(CONFIG_FILE_PATH).Close();
var encodedText = Config.VERSIONED_CODEC.Encode(SynxTranscoder.INSTANCE, Config.DEFAULT).Object().EncodeToString();
File.WriteAllText(CONFIG_FILE_PATH, encodedText);

CurrentConfig = Config.DEFAULT;
}
else
{
var decoded = Config.VERSIONED_CODEC.Decode(SynxTranscoder.INSTANCE, File.ReadAllText(CONFIG_FILE_PATH).ToSynxObject());
CurrentConfig = decoded;
if (ConfigMigrated)
{
var encoded = Config.VERSIONED_CODEC.Encode(SynxTranscoder.INSTANCE, CurrentConfig).Object().EncodeToString();
File.WriteAllText(CONFIG_FILE_PATH, encoded);
Log.Information("A migration was applied to your config and it was re-written");
}
}

Log.Information("Loaded {wallpapers} wallpapers", CurrentConfig.Wallpapers.Count);
validateConfig();
}

public void Refresh()
{
Log.Debug("Refreshing state..");
var time = TIME_OF_DAY_PROVIDER.Get();
var weather = WEATHER_PROVIDER.Get();
var season = SEASON_PROVIDER.Get();
var holiday = HOLIDAY_PROVIDER.Get();

var state = new CanopyState(time, weather, season, holiday);

if (CurrentConfig.System.ChangeSystemThemesDependingOnTime && time != lastState?.Time)
{
var theme = getThemeForTimeOfDay(time);
Log.Information("Changing system theme to {theme}", theme);
Platform.SetTheme(theme);
}

if (lastState == state)
{
Log.Debug("State is same, no updates");
return;
}

lastState = state;

var next = PickNextWallpaper(time, weather, season, holiday);
if (next == null)
{
Log.Error("No wallpapers found for current state ({state})", state);
return;
}

Log.Information("Picked new wallpaper: {pick}!", next.Path);
Log.Verbose("Setting wallpaper via {type}", Platform.GetType().Name);
Platform.SetDesktop(ResolveWallpaperPath(next.Path));

var message = new NewWallpaperMessage(DateTimeOffset.Now.ToUnixTimeMilliseconds(), next);
WebsocketServer?.Send(message);
}

private record CanopyState(TimeOfDay Time, WeatherType Weather, SeasonType Season, Holiday? Holiday);

public Wallpaper? PickNextWallpaper(TimeOfDay time, WeatherType weather, SeasonType season, Holiday? holiday)
{
if (holiday != null)
{
var holidayWallpapers = CurrentConfig.Wallpapers.Filter(w => w.Holiday.Value == holiday);
if (holidayWallpapers.IsNotEmpty())
{
var wallpaper = holidayWallpapers.Random();
return wallpaper;
}
}


var eligible = CurrentConfig.Wallpapers.Filter(w =>
containsOrEmpty(w.Season, season) &&
containsOrEmpty(w.Time, time) &&
containsOrEmpty(w.Weather, weather) &&
w.Holiday.IsMissing
);


if (eligible.IsEmpty()) return null;

var scored = eligible
.Select(w => (Wallpaper: w, Score: scoreWallpaper(w, time, weather, season)))
.ToList();

var topScore = scored.Max(s => s.Score);

Log.Verbose(" ");
Log.Verbose("Scorer:");
foreach (var (wallpaper, score) in scored)
{
Log.Verbose("{paper} - {score}", wallpaper.Path, score);
}

Log.Verbose(" ");

var topMatches = scored.Where(s => s.Score == topScore).Select(s => s.Wallpaper).ToList();

return topMatches.IsEmpty() ? null : topMatches.Random();
}

private ICanopyPlatform.Theme getThemeForTimeOfDay(TimeOfDay time) =>
time switch
{
TimeOfDay.Sunset or TimeOfDay.Morning or TimeOfDay.Afternoon => ICanopyPlatform.Theme.Light,
TimeOfDay.Sunrise or TimeOfDay.Night or TimeOfDay.DeepNight => ICanopyPlatform.Theme.Dark,
_ => ICanopyPlatform.Theme.Light
};

private static int scoreWallpaper(Wallpaper w, TimeOfDay time, WeatherType weather, SeasonType season)
{
int score = 0;
if (w.Time.Contains(time)) score++;
if (w.Weather.Contains(weather))
{
score++;
if (weather is WeatherType.Rainy or WeatherType.Stormy)
score++;
}

if (w.Season.Contains(season)) score++;
return score;
}

private static bool containsOrEmpty<T>(List<T> list, T item)
{
if (list.IsEmpty()) return true;
return list.Contains(item);
}

private void validateConfig()
{
string? error = null;
foreach (var wallpaper in CurrentConfig.Wallpapers)
{
if (!File.Exists(ResolveWallpaperPath(wallpaper.Path)))
{
error = $"Wallpaper with path {ResolveWallpaperPath(wallpaper.Path)} doesn't exist";
}

if (wallpaper.Accent != null)
{
if (wallpaper.Accent.Length != 7 || !wallpaper.Accent.StartsWith('#'))
error = $"Invalid accent format. Must be hex color like #ff00ff (Wallpaper {wallpaper.Path})";
}
}

if (error != null)
{
Log.Error(error);
Environment.Exit(0);
}
}

public static string ResolveWallpaperPath(string rawPath)
{
if (Path.IsPathRooted(rawPath))
return rawPath;

return Path.GetFullPath(Path.Combine(CANOPY_FOLDER_PATH, rawPath));
}
}
61 changes: 61 additions & 0 deletions Canopy.Core/Configuration/Config.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
// Copyright (c) 2026 SynesthesiaDev <synesthesiadev@proton.me>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.

using Codon.Codec;
using Codon.Codec.Versioned;
using SynesthesiaDev.Synx.Types;

namespace Canopy.Configuration;

public record Config(
// ReSharper disable once InconsistentNaming
string _schema,
GeneralConfig General,
SystemConfig System,
UpdaterConfig Updater,
WeatherConfig Weather,
WebsocketConfig Websocket,
List<Wallpaper> Wallpapers
)
{
public static readonly Config DEFAULT = new Config
(
_schema: "https://github.com/SynesthesiaDev/Canopy/blob/main/schema.md",
General: GeneralConfig.DEFAULT,
System: SystemConfig.DEFAULT,
Updater: UpdaterConfig.DEFAULT,
Weather: WeatherConfig.DEFAULT,
Websocket: WebsocketConfig.DEFAULT,
Wallpapers: Wallpaper.DEFAULT_WALLPAPERS
);

public static readonly StructCodec<Config> CODEC = StructCodec.For<Config>()
.Field("_schema", Codecs.STRING, c => c._schema)
.Field("General", GeneralConfig.CODEC, c => c.General)
.Field("System", SystemConfig.CODEC, c => c.System)
.Field("Updater", UpdaterConfig.CODEC, c => c.Updater)
.Field("Weather", WeatherConfig.CODEC, c => c.Weather)
.Field("Websocket", WebsocketConfig.CODEC, c => c.Websocket)
.Field("Wallpapers", Wallpaper.CODEC.List(), c => c.Wallpapers)
.Build((s, config, arg3, arg4, arg5, arg6, arg7) => new Config(s, config, arg3, arg4, arg5, arg6, arg7));

public static readonly VersionedStructCodec<Config> VERSIONED_CODEC = new VersionedStructCodec<Config>
{
CurrentSchemaVersion = 2,
InnerCodec = CODEC,
SchemaMigrationRegistry = SchemaMigrationRegistry.Builder().For<ISynxElement>(builder =>
{
builder.Add(1, (transcoder, _, output) =>
{
output.Put(transcoder.EncodeString("_schema"), transcoder.EncodeString(DEFAULT._schema));
Canopy.ConfigMigrated = true;
});
builder.Add(2, (transcoder, _, output) =>
{
output.Put(transcoder.EncodeString("ChangeSystemThemesDependingOnTime"), transcoder.EncodeBool(DEFAULT.System.ChangeSystemThemesDependingOnTime));
Canopy.ConfigMigrated = true;
});
})
};

}
Loading
Loading