Tauri 概念的 .NET 實現 - 輕巧好用的 Photino.NET
| | | 0 | |
陸續看過 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 專案結構一模一樣。只有改動幾個小地方:
- 引用 Photino.NET 套件:
dotnet package add Photino.NET - 小幅改寫 Program.cs
至於想從 C# 端主動呼叫 JavaScript 端,呼叫 PhotinoWindow.SendWebMessage() 即可。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); - 瀏覽器中使用
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> - 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