← All source files

V18 / 8d896b8a86e5

WallpaperSupport.cs

Lilith/Core/Addons/WallpaperSupport.cs

Changed in this version
CS
using System.Runtime.InteropServices;
using System.Text;
using System.Text.Json;

namespace Lilith.Core.Addons;

public sealed record AddonContext(string DataDirectory);
public interface IAddonContextAware { void Configure(AddonContext context); }

/// <summary>Small documented host primitives for crafted Windows wallpaper tools.</summary>
public static class WallpaperSupport
{
    private static readonly System.Collections.Concurrent.ConcurrentDictionary<string,DateTimeOffset> Evidence=new(StringComparer.Ordinal);
    [DllImport("user32.dll",CharSet=CharSet.Unicode,SetLastError=true,EntryPoint="SystemParametersInfoW")]
    private static extern bool Read(uint action,uint parameter,StringBuilder value,uint flags);
    [DllImport("user32.dll",CharSet=CharSet.Unicode,SetLastError=true,EntryPoint="SystemParametersInfoW")]
    private static extern bool Write(uint action,uint parameter,string value,uint flags);
    public static string GetCurrent()
    {
        if(!OperatingSystem.IsWindows())throw new PlatformNotSupportedException("Windows wallpaper only.");
        var value=new StringBuilder(32768);if(!Read(0x73,(uint)value.Capacity,value,0))throw new System.ComponentModel.Win32Exception(Marshal.GetLastWin32Error());return value.ToString();
    }
    public static void SetCurrent(string path)
    {
        if(!OperatingSystem.IsWindows())throw new PlatformNotSupportedException("Windows wallpaper only.");
        if(path.Length!=0&&!File.Exists(path))throw new FileNotFoundException("Wallpaper file missing.",path);
        if(!Write(0x14,0,path,3))throw new System.ComponentModel.Win32Exception(Marshal.GetLastWin32Error());
        if(!string.Equals(GetCurrent(),path,StringComparison.OrdinalIgnoreCase))throw new IOException("Windows did not confirm the requested wallpaper path.");
    }
    public static Task<string> SearchAsync(string query,int count=5,CancellationToken cancellationToken=default)=>
        Environment.GetEnvironmentVariable("LILITH_IMAGE_SEARCH_SOURCE")=="commons"?SearchCommonsAsync(query,count,cancellationToken):SearchNasaAsync(query,count,cancellationToken);
    private static async Task<string> SearchNasaAsync(string query,int count,CancellationToken ct)
    {
        if(query.Length is <2 or >200||count is <2 or >10)throw new ArgumentException("Use a short image query and 2–10 candidates.");
        using var http=Client();
        string searchQuery=System.Text.RegularExpressions.Regex.Replace(query,@"\b(wallpapers?|desktop|background|images?|pictures?)\b","",System.Text.RegularExpressions.RegexOptions.IgnoreCase).Trim();
        searchQuery=System.Text.RegularExpressions.Regex.Replace(searchQuery,@"\s+"," ");
        if(searchQuery.Length<2)throw new ArgumentException("Specify the image subject, such as a landscape or location.");
        async Task<JsonDocument> GetJson(string url)
        {
            var uri=new Uri(url);if(uri.Scheme!="https"||uri.Host is not("images-api.nasa.gov" or "images-assets.nasa.gov")||!uri.IsDefaultPort)throw new InvalidDataException("Unexpected NASA metadata address.");
            using var response=await http.GetAsync(uri,HttpCompletionOption.ResponseHeadersRead,ct);response.EnsureSuccessStatusCode();
            using var stream=await response.Content.ReadAsStreamAsync(ct);using var body=new MemoryStream();byte[] block=new byte[8192];int read;
            while((read=await stream.ReadAsync(block,ct))>0){if(body.Length+read>2000000)throw new InvalidDataException("Image metadata exceeds its bound.");body.Write(block,0,read);}
            var data=JsonDocument.Parse(body.ToArray());if(data.RootElement.ValueKind==JsonValueKind.String){string nested=data.RootElement.GetString()!;data.Dispose();return JsonDocument.Parse(nested);}return data;
        }
        using var search=await GetJson("https://images-api.nasa.gov/search?media_type=image&page_size=30&q="+Uri.EscapeDataString(searchQuery));
        List<object> candidates=[];List<string> failures=[];
        foreach(var item in search.RootElement.GetProperty("collection").GetProperty("items").EnumerateArray().Take(20))
        {
            ct.ThrowIfCancellationRequested();
            try
            {
                var data=item.GetProperty("data")[0];string id=data.GetProperty("nasa_id").GetString()!;
                using var manifest=await GetJson("https://images-api.nasa.gov/asset/"+Uri.EscapeDataString(id));
                var assets=manifest.RootElement.GetProperty("collection").GetProperty("items").EnumerateArray().Select(a=>a.GetProperty("href").GetString()!).Select(u=>u.StartsWith("http://images-assets.nasa.gov/",StringComparison.Ordinal)?"https://"+u[7..]:u).ToArray();
                string? image=assets.FirstOrDefault(u=>u.Contains("~orig.",StringComparison.OrdinalIgnoreCase)&&Path.GetExtension(new Uri(u).AbsolutePath).ToLowerInvariant() is ".jpg" or ".jpeg" or ".png");
                string? meta=assets.FirstOrDefault(u=>u.EndsWith("/metadata.json",StringComparison.Ordinal));if(image is null||meta is null)continue;
                using var metadata=await GetJson(meta);
                if(!metadata.RootElement.TryGetProperty("File:ImageWidth",out var w)||!metadata.RootElement.TryGetProperty("File:ImageHeight",out var h))continue;
                int width=int.Parse(w.ToString()),height=int.Parse(h.ToString());if(width<1920||height<1080||width<=height)continue;
                if(!ValidImageUrl(image))continue;
                using var headRequest=new HttpRequestMessage(HttpMethod.Head,image);using var head=await http.SendAsync(headRequest,HttpCompletionOption.ResponseHeadersRead,ct);head.EnsureSuccessStatusCode();
                if(head.Content.Headers.ContentLength>20000000||head.Content.Headers.ContentType?.MediaType is not("image/jpeg" or "image/png"))continue;
                Evidence[image]=DateTimeOffset.UtcNow.AddMinutes(10);
                candidates.Add(new{title=data.GetProperty("title").GetString(),download_url=image,source_url="https://images.nasa.gov/details/"+Uri.EscapeDataString(id),width,height,bytes=head.Content.Headers.ContentLength,provider="NASA"});if(candidates.Count>=count)break;
            }
            catch(Exception ex) when(ex is HttpRequestException or JsonException or InvalidDataException or FormatException or KeyNotFoundException){if(failures.Count<5)failures.Add(ex.Message);}
        }
        return JsonSerializer.Serialize(new{status=candidates.Count>0?"completed":"unavailable",query,search_query=searchQuery,requested=count,candidates,failures,provider="NASA"});
    }
    private static async Task<string> SearchCommonsAsync(string query,int count,CancellationToken cancellationToken)
    {
        if(query.Length is <2 or >200||count is <2 or >10)throw new ArgumentException("Use a short image query and 2–10 candidates.");
        using var http=Client();
        string uri="https://commons.wikimedia.org/w/api.php?action=query&format=json&generator=search&gsrnamespace=6&gsrlimit=30&gsrsearch="+Uri.EscapeDataString(query+" filetype:bitmap")+"&prop=imageinfo&iiprop=url|size|mime";
        using var response=await http.GetAsync(uri,HttpCompletionOption.ResponseHeadersRead,cancellationToken);response.EnsureSuccessStatusCode();
        if(response.Content.Headers.ContentLength>2000000)throw new InvalidDataException("Image search response exceeds its bound.");
        using var stream=await response.Content.ReadAsStreamAsync(cancellationToken);using var body=new MemoryStream();byte[] block=new byte[8192];int amount;
        while((amount=await stream.ReadAsync(block,cancellationToken))>0){if(body.Length+amount>2000000)throw new InvalidDataException("Image search response exceeds its bound.");body.Write(block,0,amount);}
        using var json=JsonDocument.Parse(body.ToArray());
        foreach(var old in Evidence.Where(e=>e.Value<DateTimeOffset.UtcNow))Evidence.TryRemove(old.Key,out _);
        List<object> candidates=[];
        if(json.RootElement.TryGetProperty("query",out var data)&&data.TryGetProperty("pages",out var pages))foreach(var page in pages.EnumerateObject())
        {
            if(!page.Value.TryGetProperty("imageinfo",out var infos))continue;
            var i=infos[0];int width=i.GetProperty("width").GetInt32(),height=i.GetProperty("height").GetInt32();string mime=i.GetProperty("mime").GetString()??"";
            string url=i.GetProperty("url").GetString()??"";
            if(width<1920||height<1080||width<=height||mime is not("image/jpeg" or "image/png")||i.GetProperty("size").GetInt64()>20000000||!ValidImageUrl(url))continue;
            Evidence[url]=DateTimeOffset.UtcNow.AddMinutes(10);
            candidates.Add(new{title=page.Value.GetProperty("title").GetString(),download_url=url,source_url=i.GetProperty("descriptionurl").GetString(),width,height,mime});if(candidates.Count>=count)break;
        }
        return JsonSerializer.Serialize(new{status=candidates.Count>0?"completed":"unavailable",query,requested=count,candidates});
    }
    public static async Task<string> DownloadAsync(string url,string directory,CancellationToken cancellationToken=default)
    {
        if(!ValidImageUrl(url))throw new ArgumentException("Only verified HTTPS image catalog hosts are supported.");
        if(!Evidence.TryGetValue(url,out var expires)||expires<DateTimeOffset.UtcNow)throw new InvalidOperationException("The image URL was not returned by a recent actual image search.");
        Directory.CreateDirectory(directory);
        using var http=Client();using var response=await http.GetAsync(url,HttpCompletionOption.ResponseHeadersRead,cancellationToken);response.EnsureSuccessStatusCode();
        string? mime=response.Content.Headers.ContentType?.MediaType;
        if(mime is not("image/jpeg" or "image/png")||response.Content.Headers.ContentLength>20000000)throw new InvalidDataException("Unsupported or oversized image.");
        using var input=await response.Content.ReadAsStreamAsync(cancellationToken);using var bytes=new MemoryStream();byte[] buffer=new byte[8192];int read;
        while((read=await input.ReadAsync(buffer,cancellationToken))>0){if(bytes.Length+read>20000000)throw new InvalidDataException("Image exceeds 20 MB.");await bytes.WriteAsync(buffer.AsMemory(0,read),cancellationToken);}
        byte[] content=bytes.ToArray();bool png=content.Length>8&&content.AsSpan(0,8).SequenceEqual(new byte[]{137,80,78,71,13,10,26,10});bool jpg=content.Length>3&&content[0]==255&&content[1]==216&&content[2]==255;
        if(!(mime=="image/png"&&png||mime=="image/jpeg"&&jpg))throw new InvalidDataException("Image signature mismatches content type.");
        var dimensions=Dimensions(content);if(dimensions.Width<1920||dimensions.Height<1080||dimensions.Width<=dimensions.Height)throw new InvalidDataException("Downloaded image is not a high-resolution landscape.");
        string file=Path.Combine(directory,Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(content))+(png?".png":".jpg"));await File.WriteAllBytesAsync(file,content,cancellationToken);return file;
    }
    private static bool ValidImageUrl(string url)=>Uri.TryCreate(url,UriKind.Absolute,out var uri)&&uri.Scheme=="https"&&uri.Host is "upload.wikimedia.org" or "images-assets.nasa.gov"&&uri.IsDefaultPort&&uri.UserInfo.Length==0;
    internal static (int Width,int Height) Dimensions(byte[] bytes)
    {
        if(bytes.Length>=24&&bytes[0]==137&&bytes[1]==80)return(System.Buffers.Binary.BinaryPrimitives.ReadInt32BigEndian(bytes.AsSpan(16,4)),System.Buffers.Binary.BinaryPrimitives.ReadInt32BigEndian(bytes.AsSpan(20,4)));
        if(bytes.Length>4&&bytes[0]==255&&bytes[1]==216)
        {
            int offset=2;
            while(offset+4<bytes.Length)
            {
                if(bytes[offset++]!=255)continue;while(offset<bytes.Length&&bytes[offset]==255)offset++;if(offset>=bytes.Length)break;int marker=bytes[offset++];
                if(marker is 216 or 217 or 1||marker is >=208 and <=215)continue;
                if(offset+2>bytes.Length)break;int length=(bytes[offset]<<8)|bytes[offset+1];if(length<2||offset+length>bytes.Length)break;
                if(marker is >=192 and <=207&&marker is not(196 or 200 or 204)&&length>=7)return((bytes[offset+5]<<8)|bytes[offset+6],(bytes[offset+3]<<8)|bytes[offset+4]);
                offset+=length;
            }
        }
        throw new InvalidDataException("Image dimensions could not be verified.");
    }
    private static HttpClient Client(){var client=new HttpClient(new HttpClientHandler{AllowAutoRedirect=false}){Timeout=TimeSpan.FromSeconds(45),MaxResponseContentBufferSize=20000000};client.DefaultRequestHeaders.UserAgent.ParseAdd("LilithAddonDemo/18.0 (local desktop research)");return client;}
}