陸續看過 Tauri、MAUI HybridWebView,繼續探索用網頁寫桌面小工具的賽道,這篇來介紹 Photino.NET。

Photino 是個輕量級開源框架,可用來建構介面採用 Web UI 技術 (HTML、JavaScript、CSS) 的跨平台桌面應用程式。專案受 Electron 啟發,目標也是讓開發者沿用前端技術跨平台,不必學習各平台的專屬 UI 技術。專案由 CODE Magazine 與開源社群共同維護,活躍度還行,到今年三月都還有更新。
(題外話:官方在 2026/3/26 公告,因團隊時間有限,將引進 AI 代理輔助維護流程:先用 GitHub Copilot 協助審查 PR 與分類 Issue。看來所有開發人員都免不了因應 AI 改變,才能趕上這個時代。)

Photino 由多個 NuGet 套件組成:

  • Photino.Native:各平台的原生封裝層,以 C++ (及 Objective-C) 撰寫,包裝作業系統內建的瀏覽器控制項,並針對各平台編譯
  • Photino.NET:為 Photino.Native 加上 .NET 支援,後端使用 C#,前端可搭配任何 Web 框架 (React、Vue、Angular 等)
  • Photino.Blazor:建構於 Photino.NET 之上,可完全不用 JavaScript 或 TypeScript 開發

Photino 理論上也能用於 C++、Go、Rust、Java 等語言,但目前以 .NET 為主要重心。

Photino 不是歷史悠久的成熟專案,但簡單試用後,我個人喜歡它的輕巧與簡單易用,原有網站程式不需大幅修改就能套用。一樣以我的 ASP.NET Core Minimal API 桌面小工具範例當例子,改用 Photino.NET 後的專案結構如下:

幾乎跟原本 ASP.NET Core 專案結構一模一樣。只有改動幾個小地方:

  1. 引用 Photino.NET 套件:dotnet package add Photino.NET
  2. 小幅改寫 Program.cs
    using System.Reflection;
    using System.Text.Json;
    using Photino.NET;
    
    static class Program
    {
        // Web 預設:讀取不分大小寫、輸出 camelCase,與前端 JSON 慣例一致
        static readonly JsonSerializerOptions JsonOpt = new(JsonSerializerDefaults.Web);
    
        // Windows 的 WebView2 需要 STA 執行緒
        [STAThread]
        static void Main()
        {
            var window = new PhotinoWindow()
                .SetTitle("AES Encryptor")
                .SetUseOsDefaultSize(false)
                .SetSize(640, 480)
                .SetMinSize(400, 300)
                .Center()
                .SetResizable(true)
                .SetContextMenuEnabled(false)
                // app:// 自訂 Scheme 負責提供頁面引用的內嵌資源(如 vue.global.prod.min.js)
                .RegisterCustomSchemeHandler("app", (object sender, string scheme, string url, out string contentType) =>
                    LoadEmbeddedFile(url, out contentType))
                // 前端以 window.external.sendMessage(json) 傳入,處理後用 SendWebMessage 回傳
                .RegisterWebMessageReceivedHandler((sender, message) =>
                    ((PhotinoWindow)sender!).SendWebMessage(HandleAes(message)))
                // 主頁面 HTML 從內嵌資源讀出後直接載入
                .LoadRawString(ReadEmbeddedText("index.html"));
    
            // 視窗關閉時 WaitForClose 返回,程式隨之結束,不需要額外偵測
            window.WaitForClose();
        }
    
        static Stream? OpenEmbedded(string name) =>
            Assembly.GetExecutingAssembly().GetManifestResourceStream($"ui/{name}");
    
        static string ReadEmbeddedText(string name)
        {
            using var reader = new StreamReader(OpenEmbedded(name)!);
            return reader.ReadToEnd();
        }
    
        static Stream? LoadEmbeddedFile(string url, out string contentType)
        {
            var path = new Uri(url).AbsolutePath.TrimStart('/');
            contentType = Path.GetExtension(path).ToLowerInvariant() switch
            {
                ".html" => "text/html",
                ".js" => "text/javascript",
                ".css" => "text/css",
                _ => "application/octet-stream"
            };
            return OpenEmbedded(path);
        }
    
        // 取代原本的 MapPost("/aes")
        static string HandleAes(string json)
        {
            var req = JsonSerializer.Deserialize<AesRequest>(json, JsonOpt)!;
            var encMode = req.Mode != "decrypt";
            var result = string.Empty;
            var message = string.Empty;
            try
            {
                if (string.IsNullOrEmpty(req.Key) || string.IsNullOrEmpty(req.Data))
                    throw new ArgumentException("parameter missing");
                result = encMode
                    ? CodecNetFx.AesEncrypt(req.Key, req.Data)
                    : CodecNetFx.AesDecrypt(req.Key, req.Data);
            }
            catch (Exception ex) { message = ex.Message; }
            return JsonSerializer.Serialize(
                new AesResponse(encMode ? "Encrypted" : "Plain", result, message), JsonOpt);
        }
    }
    
    record AesRequest(string? Mode, string? Key, string? Data);
    record AesResponse(string Target, string Result, string Message);
    
    至於想從 C# 端主動呼叫 JavaScript 端,呼叫 PhotinoWindow.SendWebMessage() 即可。
  3. 瀏覽器中使用 app://localhost/ 取代 http://localhost 存取後端,index.html 修改如下:
    <script src="app://localhost/vue.global.prod.min.js"></script>
    <script>
        var vm = Vue.createApp({
            data() {
                return {
                    EncKey: 'ThisIsEncryptKey',
                    Plain: 'Hello World',
                    Encrypted: '',
                    Message: '',
                    HighlightTarget: ''
                }
            },
            methods: {
                // 透過 Photino 的 IPC 將 JSON 傳給 .NET,取代原本的 form POST
                Send(mode, data) {
                    window.external.sendMessage(JSON.stringify({ mode, key: this.EncKey, data }));
                },
                Encrypt() { this.Send('encrypt', this.Plain); },
                Decrypt() { this.Send('decrypt', this.Encrypted); },
                Highlight(target) {
                    this.HighlightTarget = target;
                    let self = this;
                    setTimeout(() => self.HighlightTarget = '', 800);
                }
            }
        }).mount('#app');
        // 接收 .NET 以 SendWebMessage 回傳的結果
        window.external.receiveMessage(json => {
            const res = JSON.parse(json);
            vm[res.target] = res.result;
            vm.Message = res.message;
            vm.Highlight(res.target);
        });
    </script>
    
  4. Photino.NET 程式依賴原生程式庫 Photino.Native.dll、WebView2Loader.dll 才能運作,若想做到單一執行檔,除了 dotnet publish -c Release -r win-x64 -p:PublishSingleFile=true --no-self-contained,.csproj 還要加上 <IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract> 參考,將兩個原生程式庫 .dll 也包進去,程式啟動時會被自動解壓到暫存目錄(%TEMP%\.net\AesEncryptor.Photino\<hash>\)並載入。

實測,若客戶端已有 .NET Runtime,單檔程式體積可以小到不足 1MB,我很滿意!

Photino 的簡潔設計與輕巧易用,符合我偏愛的 KISS 極簡風格,我打算將它收進程式工具箱,未來就試著用它開發桌面小工具,若有心得再來分享。

An introduction to Photino.NET, a lightweight open-source framework for building cross-platform desktop apps with Web UI. Using an ASP.NET Core Minimal API example, it shows how few changes are needed and how to publish a single-file executable under 1MB.


Comments

Be the first to post a comment

Post a comment