2025-09-23 09:14:30 +00:00
using System ;
2025-07-14 08:31:09 +00:00
using System.Collections.Generic ;
2025-06-29 07:48:08 +00:00
using System.IO ;
using System.IO.Compression ;
using System.Linq ;
using System.Text.Json ;
using System.Threading ;
using System.Threading.Tasks ;
using System.Windows ;
using CommunityToolkit.Mvvm.DependencyInjection ;
using Flow.Launcher.Infrastructure.UserSettings ;
using Flow.Launcher.Plugin ;
2025-06-29 14:35:14 +00:00
namespace Flow.Launcher.Core.Plugin ;
2025-06-29 07:48:08 +00:00
/// <summary>
2025-07-07 11:35:12 +00:00
/// Class for installing, updating, and uninstalling plugins.
2025-06-29 07:48:08 +00:00
/// </summary>
2025-06-29 14:35:14 +00:00
public static class PluginInstaller
2025-06-29 07:48:08 +00:00
{
2025-06-29 14:35:14 +00:00
private static readonly string ClassName = nameof ( PluginInstaller ) ;
2025-06-29 07:48:08 +00:00
private static readonly Settings Settings = Ioc . Default . GetRequiredService < Settings > ( ) ;
2025-07-07 11:35:12 +00:00
/// <summary>
/// Installs a plugin and restarts the application if required by settings. Prompts user for confirmation and handles download if needed.
/// </summary>
/// <param name="newPlugin">The plugin to install.</param>
/// <returns>A Task representing the asynchronous install operation.</returns>
2025-06-29 07:48:08 +00:00
public static async Task InstallPluginAndCheckRestartAsync ( UserPlugin newPlugin )
{
2025-09-23 09:40:54 +00:00
if ( PublicApi . Instance . PluginModified ( newPlugin . ID ) )
2025-06-30 05:16:59 +00:00
{
2025-09-23 09:40:54 +00:00
PublicApi . Instance . ShowMsgError ( Localize . pluginModifiedAlreadyTitle ( newPlugin . Name ) ,
2025-09-23 09:14:30 +00:00
Localize . pluginModifiedAlreadyMessage ( ) ) ;
2025-06-30 05:16:59 +00:00
return ;
}
2025-09-23 09:40:54 +00:00
if ( PublicApi . Instance . ShowMsgBox (
2025-09-23 09:14:30 +00:00
Localize . InstallPromptSubtitle ( newPlugin . Name , newPlugin . Author , Environment . NewLine ) ,
Localize . InstallPromptTitle ( ) ,
2025-06-29 07:48:08 +00:00
button : MessageBoxButton . YesNo ) ! = MessageBoxResult . Yes ) return ;
try
{
// at minimum should provide a name, but handle plugin that is not downloaded from plugins manifest and is a url download
var downloadFilename = string . IsNullOrEmpty ( newPlugin . Version )
? $"{newPlugin.Name}-{Guid.NewGuid()}.zip"
: $"{newPlugin.Name}-{newPlugin.Version}.zip" ;
var filePath = Path . Combine ( Path . GetTempPath ( ) , downloadFilename ) ;
using var cts = new CancellationTokenSource ( ) ;
if ( ! newPlugin . IsFromLocalInstallPath )
{
await DownloadFileAsync (
2025-09-23 09:14:30 +00:00
$"{Localize.DownloadingPlugin()} {newPlugin.Name}" ,
2025-06-29 07:48:08 +00:00
newPlugin . UrlDownload , filePath , cts ) ;
}
else
{
filePath = newPlugin . LocalInstallPath ;
}
// check if user cancelled download before installing plugin
if ( cts . IsCancellationRequested )
{
return ;
}
2025-06-29 07:58:52 +00:00
if ( ! File . Exists ( filePath ) )
2025-06-29 07:48:08 +00:00
{
2025-06-29 07:58:52 +00:00
throw new FileNotFoundException ( $"Plugin {newPlugin.ID} zip file not found at {filePath}" , filePath ) ;
}
2025-06-29 07:48:08 +00:00
2025-09-23 09:40:54 +00:00
if ( ! PublicApi . Instance . InstallPlugin ( newPlugin , filePath ) )
2025-07-01 01:05:21 +00:00
{
return ;
}
2025-06-29 07:48:08 +00:00
2025-06-29 07:58:52 +00:00
if ( ! newPlugin . IsFromLocalInstallPath )
{
File . Delete ( filePath ) ;
2025-06-29 07:48:08 +00:00
}
}
catch ( Exception e )
{
2025-09-23 09:40:54 +00:00
PublicApi . Instance . LogException ( ClassName , "Failed to install plugin" , e ) ;
PublicApi . Instance . ShowMsgError ( Localize . ErrorInstallingPlugin ( ) ) ;
2025-06-30 04:58:44 +00:00
return ; // do not restart on failure
2025-06-29 07:48:08 +00:00
}
if ( Settings . AutoRestartAfterChanging )
{
2025-09-23 09:40:54 +00:00
PublicApi . Instance . RestartApp ( ) ;
2025-06-29 07:48:08 +00:00
}
else
{
2025-09-23 09:40:54 +00:00
PublicApi . Instance . ShowMsg (
2025-09-23 09:14:30 +00:00
Localize . installbtn ( ) ,
Localize . InstallSuccessNoRestart ( newPlugin . Name ) ) ;
2025-06-29 07:48:08 +00:00
}
}
2025-07-07 11:35:12 +00:00
/// <summary>
/// Installs a plugin from a local zip file and restarts the application if required by settings. Validates the zip and prompts user for confirmation.
/// </summary>
/// <param name="filePath">The path to the plugin zip file.</param>
/// <returns>A Task representing the asynchronous install operation.</returns>
2025-06-29 07:48:08 +00:00
public static async Task InstallPluginAndCheckRestartAsync ( string filePath )
{
UserPlugin plugin ;
try
{
using ZipArchive archive = ZipFile . OpenRead ( filePath ) ;
2025-06-29 08:09:06 +00:00
var pluginJsonEntry = archive . Entries . FirstOrDefault ( x = > x . Name = = "plugin.json" ) ? ?
2025-06-29 07:48:08 +00:00
throw new FileNotFoundException ( "The zip file does not contain a plugin.json file." ) ;
using Stream stream = pluginJsonEntry . Open ( ) ;
plugin = JsonSerializer . Deserialize < UserPlugin > ( stream ) ;
plugin . IcoPath = "Images\\zipfolder.png" ;
plugin . LocalInstallPath = filePath ;
}
catch ( Exception e )
{
2025-09-23 09:40:54 +00:00
PublicApi . Instance . LogException ( ClassName , "Failed to validate zip file" , e ) ;
PublicApi . Instance . ShowMsgError ( Localize . ZipFileNotHavePluginJson ( ) ) ;
2025-06-29 07:48:08 +00:00
return ;
}
2025-09-23 09:40:54 +00:00
if ( PublicApi . Instance . PluginModified ( plugin . ID ) )
2025-06-30 05:16:59 +00:00
{
2025-09-23 09:40:54 +00:00
PublicApi . Instance . ShowMsgError ( Localize . pluginModifiedAlreadyTitle ( plugin . Name ) ,
2025-09-23 09:14:30 +00:00
Localize . pluginModifiedAlreadyMessage ( ) ) ;
2025-06-30 05:16:59 +00:00
return ;
}
2025-06-29 07:48:08 +00:00
if ( Settings . ShowUnknownSourceWarning )
{
if ( ! InstallSourceKnown ( plugin . Website )
2025-09-23 09:40:54 +00:00
& & PublicApi . Instance . ShowMsgBox ( Localize . InstallFromUnknownSourceSubtitle ( Environment . NewLine ) ,
2025-09-23 09:14:30 +00:00
Localize . InstallFromUnknownSourceTitle ( ) ,
2025-06-29 07:48:08 +00:00
MessageBoxButton . YesNo ) = = MessageBoxResult . No )
return ;
}
await InstallPluginAndCheckRestartAsync ( plugin ) ;
}
2025-07-07 11:35:12 +00:00
/// <summary>
/// Uninstalls a plugin and restarts the application if required by settings. Prompts user for confirmation and whether to keep plugin settings.
/// </summary>
/// <param name="oldPlugin">The plugin metadata to uninstall.</param>
/// <returns>A Task representing the asynchronous uninstall operation.</returns>
2025-06-29 07:48:08 +00:00
public static async Task UninstallPluginAndCheckRestartAsync ( PluginMetadata oldPlugin )
{
2025-09-23 09:40:54 +00:00
if ( PublicApi . Instance . PluginModified ( oldPlugin . ID ) )
2025-06-30 05:16:59 +00:00
{
2025-09-23 09:40:54 +00:00
PublicApi . Instance . ShowMsgError ( Localize . pluginModifiedAlreadyTitle ( oldPlugin . Name ) ,
2025-09-23 09:14:30 +00:00
Localize . pluginModifiedAlreadyMessage ( ) ) ;
2025-06-30 05:16:59 +00:00
return ;
}
2025-09-23 09:40:54 +00:00
if ( PublicApi . Instance . ShowMsgBox (
2025-09-23 09:14:30 +00:00
Localize . UninstallPromptSubtitle ( oldPlugin . Name , oldPlugin . Author , Environment . NewLine ) ,
Localize . UninstallPromptTitle ( ) ,
2025-06-29 07:48:08 +00:00
button : MessageBoxButton . YesNo ) ! = MessageBoxResult . Yes ) return ;
2025-09-23 09:40:54 +00:00
var removePluginSettings = PublicApi . Instance . ShowMsgBox (
2025-09-23 09:14:30 +00:00
Localize . KeepPluginSettingsSubtitle ( ) ,
Localize . KeepPluginSettingsTitle ( ) ,
2025-06-29 07:48:08 +00:00
button : MessageBoxButton . YesNo ) = = MessageBoxResult . No ;
try
{
2025-09-23 09:40:54 +00:00
if ( ! await PublicApi . Instance . UninstallPluginAsync ( oldPlugin , removePluginSettings ) )
2025-07-01 01:05:21 +00:00
{
return ;
}
2025-06-29 07:48:08 +00:00
}
catch ( Exception e )
{
2025-09-23 09:40:54 +00:00
PublicApi . Instance . LogException ( ClassName , "Failed to uninstall plugin" , e ) ;
PublicApi . Instance . ShowMsgError ( Localize . ErrorUninstallingPlugin ( ) ) ;
2025-06-30 04:58:44 +00:00
return ; // don not restart on failure
2025-06-29 07:48:08 +00:00
}
if ( Settings . AutoRestartAfterChanging )
{
2025-09-23 09:40:54 +00:00
PublicApi . Instance . RestartApp ( ) ;
2025-06-29 07:48:08 +00:00
}
else
{
2025-09-23 09:40:54 +00:00
PublicApi . Instance . ShowMsg (
2025-09-23 09:14:30 +00:00
Localize . uninstallbtn ( ) ,
Localize . UninstallSuccessNoRestart ( oldPlugin . Name ) ) ;
2025-06-29 07:48:08 +00:00
}
}
2025-07-07 11:35:12 +00:00
/// <summary>
/// Updates a plugin to a new version and restarts the application if required by settings. Prompts user for confirmation and handles download if needed.
/// </summary>
/// <param name="newPlugin">The new plugin version to install.</param>
/// <param name="oldPlugin">The existing plugin metadata to update.</param>
/// <returns>A Task representing the asynchronous update operation.</returns>
2025-06-29 07:48:08 +00:00
public static async Task UpdatePluginAndCheckRestartAsync ( UserPlugin newPlugin , PluginMetadata oldPlugin )
{
2025-09-23 09:40:54 +00:00
if ( PublicApi . Instance . ShowMsgBox (
2025-09-23 09:14:30 +00:00
Localize . UpdatePromptSubtitle ( oldPlugin . Name , oldPlugin . Author , Environment . NewLine ) ,
Localize . UpdatePromptTitle ( ) ,
2025-06-29 07:48:08 +00:00
button : MessageBoxButton . YesNo ) ! = MessageBoxResult . Yes ) return ;
try
{
var filePath = Path . Combine ( Path . GetTempPath ( ) , $"{newPlugin.Name}-{newPlugin.Version}.zip" ) ;
using var cts = new CancellationTokenSource ( ) ;
if ( ! newPlugin . IsFromLocalInstallPath )
{
await DownloadFileAsync (
2025-09-23 09:14:30 +00:00
$"{Localize.DownloadingPlugin()} {newPlugin.Name}" ,
2025-06-29 07:48:08 +00:00
newPlugin . UrlDownload , filePath , cts ) ;
}
else
{
filePath = newPlugin . LocalInstallPath ;
}
// check if user cancelled download before installing plugin
if ( cts . IsCancellationRequested )
{
return ;
}
2025-06-29 07:58:52 +00:00
2025-09-23 09:40:54 +00:00
if ( ! await PublicApi . Instance . UpdatePluginAsync ( oldPlugin , newPlugin , filePath ) )
2025-07-01 01:05:21 +00:00
{
return ;
}
2025-06-29 07:48:08 +00:00
}
catch ( Exception e )
{
2025-09-23 09:40:54 +00:00
PublicApi . Instance . LogException ( ClassName , "Failed to update plugin" , e ) ;
PublicApi . Instance . ShowMsgError ( Localize . ErrorUpdatingPlugin ( ) ) ;
2025-06-30 04:58:44 +00:00
return ; // do not restart on failure
2025-06-29 07:48:08 +00:00
}
if ( Settings . AutoRestartAfterChanging )
{
2025-09-23 09:40:54 +00:00
PublicApi . Instance . RestartApp ( ) ;
2025-06-29 07:48:08 +00:00
}
else
{
2025-09-23 09:40:54 +00:00
PublicApi . Instance . ShowMsg (
2025-09-23 09:14:30 +00:00
Localize . updatebtn ( ) ,
Localize . UpdateSuccessNoRestart ( newPlugin . Name ) ) ;
2025-06-29 07:48:08 +00:00
}
}
2025-07-14 08:18:22 +00:00
/// <summary>
/// Updates the plugin to the latest version available from its source.
/// </summary>
2025-07-18 06:22:05 +00:00
/// <param name="updateAllPlugins">Action to execute when the user chooses to update all plugins.</param>
2025-07-15 12:10:07 +00:00
/// <param name="silentUpdate">If true, do not show any messages when there is no update available.</param>
2025-07-14 08:18:22 +00:00
/// <param name="usePrimaryUrlOnly">If true, only use the primary URL for updates.</param>
/// <param name="token">Cancellation token to cancel the update operation.</param>
/// <returns></returns>
2025-07-18 06:22:05 +00:00
public static async Task CheckForPluginUpdatesAsync ( Action < List < PluginUpdateInfo > > updateAllPlugins , bool silentUpdate = true , bool usePrimaryUrlOnly = false , CancellationToken token = default )
2025-07-14 08:18:22 +00:00
{
// Update the plugin manifest
2025-09-23 09:40:54 +00:00
await PublicApi . Instance . UpdatePluginManifestAsync ( usePrimaryUrlOnly , token ) ;
2025-07-14 08:18:22 +00:00
// Get all plugins that can be updated
var resultsForUpdate = (
2025-09-23 09:40:54 +00:00
from existingPlugin in PublicApi . Instance . GetAllPlugins ( )
join pluginUpdateSource in PublicApi . Instance . GetPluginManifest ( )
2025-07-14 08:18:22 +00:00
on existingPlugin . Metadata . ID equals pluginUpdateSource . ID
where string . Compare ( existingPlugin . Metadata . Version , pluginUpdateSource . Version ,
StringComparison . InvariantCulture ) <
0 // if current version precedes version of the plugin from update source (e.g. PluginsManifest)
2025-09-23 09:40:54 +00:00
& & ! PublicApi . Instance . PluginModified ( existingPlugin . Metadata . ID )
2025-07-14 08:18:22 +00:00
select
2025-07-14 08:56:12 +00:00
new PluginUpdateInfo ( )
2025-07-14 08:18:22 +00:00
{
2025-07-14 08:56:12 +00:00
ID = existingPlugin . Metadata . ID ,
Name = existingPlugin . Metadata . Name ,
Author = existingPlugin . Metadata . Author ,
2025-07-14 08:18:22 +00:00
CurrentVersion = existingPlugin . Metadata . Version ,
NewVersion = pluginUpdateSource . Version ,
2025-07-14 08:56:12 +00:00
IcoPath = existingPlugin . Metadata . IcoPath ,
2025-07-14 08:18:22 +00:00
PluginExistingMetadata = existingPlugin . Metadata ,
PluginNewUserPlugin = pluginUpdateSource
} ) . ToList ( ) ;
// No updates
2025-09-23 09:14:30 +00:00
if ( resultsForUpdate . Count = = 0 )
2025-07-14 08:18:22 +00:00
{
if ( ! silentUpdate )
{
2025-09-23 09:40:54 +00:00
PublicApi . Instance . ShowMsg ( Localize . updateNoResultTitle ( ) , Localize . updateNoResultSubtitle ( ) ) ;
2025-07-14 08:18:22 +00:00
}
return ;
}
// If all plugins are modified, just return
2025-09-23 09:40:54 +00:00
if ( resultsForUpdate . All ( x = > PublicApi . Instance . PluginModified ( x . ID ) ) )
2025-07-14 08:18:22 +00:00
{
return ;
}
2025-07-14 08:31:09 +00:00
// Show message box with button to update all plugins
2025-09-23 09:40:54 +00:00
PublicApi . Instance . ShowMsgWithButton (
2025-09-23 09:14:30 +00:00
Localize . updateAllPluginsTitle ( ) ,
Localize . updateAllPluginsButtonContent ( ) ,
2025-07-14 08:31:09 +00:00
( ) = >
{
2025-07-18 06:22:05 +00:00
updateAllPlugins ( resultsForUpdate ) ;
2025-07-14 08:31:09 +00:00
} ,
string . Join ( ", " , resultsForUpdate . Select ( x = > x . PluginExistingMetadata . Name ) ) ) ;
}
2025-07-14 08:18:22 +00:00
2025-07-18 06:22:05 +00:00
/// <summary>
/// Updates all plugins that have available updates.
/// </summary>
/// <param name="resultsForUpdate"></param>
/// <param name="restart"></param>
public static async Task UpdateAllPluginsAsync ( IEnumerable < PluginUpdateInfo > resultsForUpdate , bool restart )
2025-07-14 08:31:09 +00:00
{
2025-07-18 06:22:05 +00:00
var anyPluginSuccess = false ;
await Task . WhenAll ( resultsForUpdate . Select ( async plugin = >
2025-07-14 08:18:22 +00:00
{
var downloadToFilePath = Path . Combine ( Path . GetTempPath ( ) , $"{plugin.Name}-{plugin.NewVersion}.zip" ) ;
try
{
using var cts = new CancellationTokenSource ( ) ;
await DownloadFileAsync (
2025-09-23 09:14:30 +00:00
$"{Localize.DownloadingPlugin()} {plugin.PluginNewUserPlugin.Name}" ,
2025-07-14 08:18:22 +00:00
plugin . PluginNewUserPlugin . UrlDownload , downloadToFilePath , cts ) ;
// check if user cancelled download before installing plugin
if ( cts . IsCancellationRequested )
{
return ;
}
2025-09-23 09:40:54 +00:00
if ( ! await PublicApi . Instance . UpdatePluginAsync ( plugin . PluginExistingMetadata , plugin . PluginNewUserPlugin , downloadToFilePath ) )
2025-07-14 08:18:22 +00:00
{
return ;
}
2025-07-18 06:22:05 +00:00
anyPluginSuccess = true ;
2025-07-14 08:18:22 +00:00
}
catch ( Exception e )
{
2025-09-23 09:40:54 +00:00
PublicApi . Instance . LogException ( ClassName , "Failed to update plugin" , e ) ;
PublicApi . Instance . ShowMsgError ( Localize . ErrorUpdatingPlugin ( ) ) ;
2025-07-14 08:18:22 +00:00
}
} ) ) ;
2025-07-18 06:22:05 +00:00
if ( ! anyPluginSuccess ) return ;
if ( restart )
{
2025-09-23 09:40:54 +00:00
PublicApi . Instance . RestartApp ( ) ;
2025-07-18 06:22:05 +00:00
}
else
{
2025-09-23 09:40:54 +00:00
PublicApi . Instance . ShowMsg (
2025-09-23 09:14:30 +00:00
Localize . updatebtn ( ) ,
Localize . PluginsUpdateSuccessNoRestart ( ) ) ;
2025-07-18 06:22:05 +00:00
}
2025-07-14 08:18:22 +00:00
}
2025-07-07 11:35:12 +00:00
/// <summary>
/// Downloads a file from a URL to a local path, optionally showing a progress box and handling cancellation.
/// </summary>
/// <param name="progressBoxTitle">The title for the progress box.</param>
/// <param name="downloadUrl">The URL to download from.</param>
/// <param name="filePath">The local file path to save to.</param>
/// <param name="cts">Cancellation token source for cancelling the download.</param>
/// <param name="deleteFile">Whether to delete the file if it already exists.</param>
/// <param name="showProgress">Whether to show a progress box during download.</param>
/// <returns>A Task representing the asynchronous download operation.</returns>
private static async Task DownloadFileAsync ( string progressBoxTitle , string downloadUrl , string filePath , CancellationTokenSource cts , bool deleteFile = true , bool showProgress = true )
2025-06-29 07:48:08 +00:00
{
if ( deleteFile & & File . Exists ( filePath ) )
File . Delete ( filePath ) ;
if ( showProgress )
{
var exceptionHappened = false ;
2025-09-23 09:40:54 +00:00
await PublicApi . Instance . ShowProgressBoxAsync ( progressBoxTitle ,
2025-06-29 07:48:08 +00:00
async ( reportProgress ) = >
{
if ( reportProgress = = null )
{
2025-06-29 10:20:47 +00:00
// when reportProgress is null, it means there is exception with the progress box
2025-06-29 07:48:08 +00:00
// so we record it with exceptionHappened and return so that progress box will close instantly
exceptionHappened = true ;
return ;
}
else
{
2025-09-23 09:40:54 +00:00
await PublicApi . Instance . HttpDownloadAsync ( downloadUrl , filePath , reportProgress , cts . Token ) . ConfigureAwait ( false ) ;
2025-06-29 07:48:08 +00:00
}
} , cts . Cancel ) ;
// if exception happened while downloading and user does not cancel downloading,
// we need to redownload the plugin
if ( exceptionHappened & & ( ! cts . IsCancellationRequested ) )
2025-09-23 09:40:54 +00:00
await PublicApi . Instance . HttpDownloadAsync ( downloadUrl , filePath , token : cts . Token ) . ConfigureAwait ( false ) ;
2025-06-29 07:48:08 +00:00
}
else
{
2025-09-23 09:40:54 +00:00
await PublicApi . Instance . HttpDownloadAsync ( downloadUrl , filePath , token : cts . Token ) . ConfigureAwait ( false ) ;
2025-06-29 07:48:08 +00:00
}
}
2025-07-07 11:35:12 +00:00
/// <summary>
/// Determines if the plugin install source is a known/approved source (e.g., GitHub and matches an existing plugin author).
/// </summary>
/// <param name="url">The URL to check.</param>
/// <returns>True if the source is known, otherwise false.</returns>
2025-06-29 07:48:08 +00:00
private static bool InstallSourceKnown ( string url )
{
2025-06-29 08:29:45 +00:00
if ( string . IsNullOrEmpty ( url ) )
return false ;
2025-06-29 07:48:08 +00:00
var pieces = url . Split ( '/' ) ;
if ( pieces . Length < 4 )
return false ;
var author = pieces [ 3 ] ;
2025-06-29 09:06:29 +00:00
var acceptedHost = "github.com" ;
2025-06-29 07:48:08 +00:00
var acceptedSource = "https://github.com" ;
var constructedUrlPart = string . Format ( "{0}/{1}/" , acceptedSource , author ) ;
2025-06-29 09:06:29 +00:00
if ( ! Uri . TryCreate ( url , UriKind . Absolute , out var uri ) | | uri . Host ! = acceptedHost )
return false ;
2025-09-23 09:40:54 +00:00
return PublicApi . Instance . GetAllPlugins ( ) . Any ( x = >
2025-06-29 09:06:29 +00:00
! string . IsNullOrEmpty ( x . Metadata . Website ) & &
x . Metadata . Website . StartsWith ( constructedUrlPart )
) ;
2025-06-29 07:48:08 +00:00
}
2025-07-18 06:22:05 +00:00
}
2025-07-14 08:56:12 +00:00
2025-07-18 06:22:05 +00:00
public record PluginUpdateInfo
{
public string ID { get ; init ; }
public string Name { get ; init ; }
public string Author { get ; init ; }
public string CurrentVersion { get ; init ; }
public string NewVersion { get ; init ; }
public string IcoPath { get ; init ; }
public PluginMetadata PluginExistingMetadata { get ; init ; }
public UserPlugin PluginNewUserPlugin { get ; init ; }
2025-06-29 07:48:08 +00:00
}