開發(fā)桌面客戶端軟件一直是程序員的常見任務(wù)之一,而Go語言憑借其簡潔、高效以及豐富的第三方庫,越來越多地被用于開發(fā)各類應(yīng)用程序。今天我們將結(jié)合Go語言和HTML,使用開源項(xiàng)目 Sciter 的 Go 綁定庫 go-sciter,為大家展示如何用最少的開發(fā)精力構(gòu)建一個(gè)跨平臺的桌面客戶端。
什么是Sciter?
Sciter 是一個(gè)支持多平臺的嵌入式HTML/CSS/腳本引擎,適用于構(gòu)建本地桌面應(yīng)用程序,且它的性能非常好。通過 go-sciter,我們可以用Go語言來調(diào)用Sciter引擎,進(jìn)而使用HTML、CSS和JavaScript創(chuàng)建用戶界面,并與Go的后端邏輯交互。
為什么選擇Sciter?
- 輕量級: Sciter非常輕量,適合需要快速構(gòu)建的桌面應(yīng)用。
- 跨平臺: 支持Windows、macOS和Linux操作系統(tǒng)。
- 無需第三方瀏覽器依賴: 與Electron不同,Sciter不需要依賴外部的瀏覽器引擎,極大減少了應(yīng)用程序的體積。
- 使用現(xiàn)代的前端技術(shù): 支持HTML5、CSS3和JavaScript,前端開發(fā)者可以快速上手。
準(zhǔn)備工作
1. 安裝Go:
我們默認(rèn)認(rèn)為你已經(jīng)安裝了Go。如果沒有安裝,可以從 Go官網(wǎng) 下載并安裝。安裝完成后,執(zhí)行以下命令確認(rèn)Go是否正確安裝:
go version
注意
因?yàn)槭莄go開發(fā),因此 WIndows 用戶還需要安裝 mingw64-gcc。
2. 安裝Sciter SDK
前往 Sciter官網(wǎng) 下載Sciter SDK,選擇適合你操作系統(tǒng)的版本(Windows、macOS或Linux)。解壓后將 bin 目錄中的動(dòng)態(tài)庫文件(dll、so或dylib)放到系統(tǒng)的環(huán)境變量中,或者與可執(zhí)行文件一起存放,具體請看 Sciter 官方文檔。
注意事項(xiàng)
由于 go-sciter 這兩年沒有及時(shí)更新,其實(shí)最新的 Sciter SDK 并不適合使用,因此你需要下載 4.4.8 版本的 Sciter SDK,太新的不行。
3. 安裝go-sciter
通過Go命令安裝 go-sciter 包:
go get github.com/sciter-sdk/go-sciter
開始編寫客戶端程序
為了方便舉例,我以當(dāng)前隨手寫的一個(gè)桌面應(yīng)用為例,展開說明。
簡單說明一下,這個(gè)項(xiàng)目的功能是:自動(dòng)提交網(wǎng)站的 URL 到 Google 推送服務(wù)器。下面不是一個(gè)完整的項(xiàng)目代碼,因?yàn)檫€涉及到數(shù)據(jù)庫操作,網(wǎng)站Sitemap的扒取等等,因此只列出了重要的部分。
首先創(chuàng)建一個(gè)項(xiàng)目,我們暫且取名為 gosciter 吧。創(chuàng)建項(xiàng)目的過程不贅述。
將 sciter.dll 或者 libsciter.dylib(MacOS用戶) 放到項(xiàng)目根目錄下。
如果需要用到 sqlite 數(shù)據(jù)庫,也需要拷貝 sciter-sqlite.dll 或者 sciter-sqlite.dylib 過來。我的項(xiàng)目用到了,因此這個(gè)文件也復(fù)制過來了。
編寫 main.go
package main
import (
"embed"
"encoding/json"
"fmt"
"github.com/ncruces/zenity"
"github.com/sciter-sdk/go-sciter"
"github.com/sciter-sdk/go-sciter/window"
"github.com/skratchdot/open-golang/open"
"log"
"os"
"strconv"
"strings"
"time"
)
// 為了讓生成的可執(zhí)行文件包含了界面文件,直接把views文件夾嵌入到可執(zhí)行文件中
//go:embed all:views
var views embed.FS
// 定義一個(gè)Map類型的數(shù)據(jù)結(jié)構(gòu)
type Map map[string]interface{}
func main() {
w, err := window.New(sciter.SW_TITLEBAR|sciter.SW_RESIZEABLE|sciter.SW_CONTROLS|sciter.SW_MAIN|sciter.SW_ENABLE_DEBUG, &sciter.Rect{
Left: 100,
Top: 50,
Right: 1100,
Bottom: 660,
})
if err != nil {
log.Fatal(err)
}
// 定義一個(gè)回調(diào)函數(shù),用于處理加載資源,home 是自定義的Scheme
w.SetCallback(&sciter.CallbackHandler{
OnLoadData: func(params *sciter.ScnLoadData) int {
if strings.HasPrefix(params.Uri(), "home://") {
fileData, err := views.ReadFile(params.Uri()[7:])
if err == nil {
w.DataReady(params.Uri()[7:], fileData)
}
}
return 0
},
})
// 這里定義一些與前端交互的函數(shù)
w.DefineFunction("openUrl", openUrl)
w.DefineFunction("getIndexingTasks", getIndexingTasks)
w.DefineFunction("getIndexingTask", getIndexingTask)
w.DefineFunction("getIndexingUrls", getIndexingUrls)
w.DefineFunction("openAccountJson", openAccountJson)
w.DefineFunction("loadIndexingSitemap", loadIndexingSitemap)
w.DefineFunction("createGoogleIndexing", createGoogleIndexing)
w.DefineFunction("startGoogleIndexing", startGoogleIndexing)
w.DefineFunction("stopGoogleIndexing", stopGoogleIndexing)
w.DefineFunction("deleteGoogleIndexing", deleteGoogleIndexing)
// 加載主頁面
mainView, err := views.ReadFile("views/main.html")
if err != nil {
fmt.Print("nofile", err)
os.Exit(0)
}
w.LoadHtml(string(mainView), "")
w.SetTitle("谷歌推送")
w.Show()
w.Run()
}
func openUrl(args ...*sciter.Value) *sciter.Value {
link := args[0].String()
_ = open.Run(link)
return nil
}
func getIndexingTasks(args ...*sciter.Value) *sciter.Value {
//tasks := service.GetIndexingTasks()
var task = []Map{}
// 返回Json格式
return jsonValue(tasks)
}
func getIndexingTask(args ...*sciter.Value) *sciter.Value {
index, _ := strconv.Atoi(args[0].String())
//task := service.GetIndexingTask(index)
task := Map{}
// 返回Json格式
return jsonValue(task)
}
func getIndexingUrls(args ...*sciter.Value) *sciter.Value {
index, _ := strconv.Atoi(args[0].String())
page, _ := strconv.Atoi(args[1].String())
if page < 1 {
page = 1
}
//urls, totalPage := service.GetIndexingUrls(index, page)
urls := []string{}
totalPage := 0
// 返回Json格式
return jsonValue(Map{"urls": urls, "page": page, "totalPage": totalPage})
}
func openAccountJson(args ...*sciter.Value) *sciter.Value {
accountPath, err := zenity.SelectFile(zenity.Title("選擇Account Json文件"), zenity.FileFilter{
Name: "Json file",
Patterns: []string{"*.json"},
CaseFold: false,
})
if err != nil || accountPath == "" {
fmt.Println(err)
return nil
}
return sciter.NewValue(accountPath)
}
func createGoogleIndexing(args ...*sciter.Value) *sciter.Value {
accountPath := args[0].String()
domain := args[1].String()
tmpNum := args[2].String()
dailyNum, _ := strconv.Atoi(tmpNum)
if dailyNum == 0 {
dailyNum = 200
}
if !strings.HasPrefix(domain, "http") {
return sciter.NewValue("網(wǎng)址填寫錯(cuò)誤")
}
// err := service.CreateIndexing(accountPath, domain, dailyNum)
// if err != nil {
// return sciter.NewValue(err.Error())
// }
return nil
}
func loadIndexingSitemap(args ...*sciter.Value) *sciter.Value {
index, _ := strconv.Atoi(args[0].String())
// err := service.LoadIndexingSitemap(index, false)
// if err != nil {
// return sciter.NewValue(err.Error())
// }
return nil
}
func startGoogleIndexing(args ...*sciter.Value) *sciter.Value {
index, _ := strconv.Atoi(args[0].String())
// err := service.StartGoogleIndexing(index)
// if err != nil {
// return sciter.NewValue(err.Error())
// }
return nil
}
func stopGoogleIndexing(args ...*sciter.Value) *sciter.Value {
index, _ := strconv.Atoi(args[0].String())
//service.StopGoogleIndexing(index)
return nil
}
func deleteGoogleIndexing(args ...*sciter.Value) *sciter.Value {
index, _ := strconv.Atoi(args[0].String())
// 需要先stop
// service.StopGoogleIndexing(index)
// // 最后刪除
// service.DeleteIndexingTask(index)
return nil
}
func jsonValue(val interface{}) *sciter.Value {
buf, err := json.Marshal(val)
if err != nil {
return nil
}
return sciter.NewValue(string(buf))
}
編寫 views/main.html
主頁面沒有什么特別之處,只是使用了自定義的scheme home://
<html resizeable>
<head>
<style src="home://views/style.css" />
<meta charSet="utf-8" />
</head>
<body>
<div class="layout">
<div class="aside">
<h1 class="soft-title"><a href="home://views/main.html">谷歌<br/>推送助手</a></h1>
<div class="aside-menus">
<a href="home://views/task.html" class="menu-item">推送任務(wù)</a>
<a href="home://views/help.html" class="menu-item">使用教程</a>
</div>
</div>
<div class="container">
<div class="home">
<div>歡迎使用 谷歌推送助手</div>
<div class="start-control">
<a href="home://views/task.html" class="start-btn">開始使用</a>
</div>
</div>
</div>
</div>
</body>
</html>
編寫 views/task.html
主要的任務(wù)界面,這里則進(jìn)行了列表渲染,上下翻頁,以及按鈕操作等處理。
<html resizeable>
<head>
<style src="home://views/style.css" />
<meta charSet="utf-8" />
</head>
<body>
<div class="layout">
<div class="aside">
<h1 class="soft-title"><a href="home://views/main.html">谷歌<br/>推送助手</a></h1>
<div class="aside-menus">
<a href="home://views/task.html" class="menu-item active">推送任務(wù)</a>
<a href="home://views/help.html" class="menu-item">使用教程</a>
</div>
</div>
<div class="container">
<div class="task-head">
<button #newTask>新建任務(wù)</button>
</div>
<table class="task-list" #taskList>
<colgroup>
<col width="30%">
<col width="15%">
<col width="15%">
<col width="15%">
<col width="30%">
</colgroup>
<thead>
<tr>
<th>站點(diǎn)域名</th>
<th>URL數(shù)量</th>
<th>已推送/每日推送</th>
<th>狀態(tài)</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr>
<td colspan="5">加載中</td>
</tr>
</tbody>
</table>
</div>
<form class="control-form" #taslForm>
<div class="form-header">
<a class="form-close" #resultClose>關(guān)閉</a>
<h3>創(chuàng)建/編輯任務(wù)</h3>
</div>
<div class="form-content">
<div class="form-item">
<div class="form-label">網(wǎng)址或Sitemap地址:</div>
<div class="input-block">
<input(domain) class="layui-input" type="text" placeholder="http://或https://開頭的網(wǎng)站地址或Sitemap地址" />
<div class="text-muted">說明:如果填寫了Sitemap地址,將自動(dòng)獲取Sitemap中的所有URL推送,<br/>否則將抓取推送網(wǎng)址下的所有鏈接。</div>
</div>
</div>
<div class="form-item">
<div class="form-label">選擇AccountJson:</div>
<div class="input-block text-left">
<div>
<button #selectAccountJson>選擇.json文件</button>
<span #accountJson></span>
</div>
<div class="text-muted">說明:需要上傳谷歌賬號的json文件,用于授權(quán)。</div>
</div>
</div>
<div class="form-item">
<div class="form-label">每天推送數(shù)量:</div>
<div class="input-block">
<input(daily_num) class="layui-input" type="text" placeholder="默認(rèn)200" />
<div class="text-muted">說明:請根據(jù)你的接口限制,填寫每天推送的量。</div>
</div>
</div>
<div>
<button type="default" #formClose>返回</button>
<button type="default" #taskSubmit>提交</button>
</div>
</div>
</form>
<div class="result-list" #resultList>
<div class="form-header">
<a class="form-close" #resultClose>關(guān)閉</a>
<h3>查看結(jié)果</h3>
</div>
<div class="form-content">
<table>
<colgroup>
<col width="40%">
<col width="60%">
</colgroup>
<tbody>
<tr>
<td>網(wǎng)站網(wǎng)站</td>
<td #resultDomain></td>
</tr>
<tr>
<td>每日推送數(shù)量</td>
<td #resultDailyNum>0條</td>
</tr>
<tr>
<td>執(zhí)行狀態(tài)</td>
<td #resultStatus>waiting</td>
</tr>
<tr>
<td>已發(fā)現(xiàn)URL</td>
<td #resultUrlCount>0條</td>
</tr>
<tr>
<td>已推送</td>
<td #resultDailyFinished>0條</td>
</tr>
<tr>
<td>推送結(jié)果</td>
<td class="text-left" #resultResult>
/* <div><span>https://www.anqicms.com</span><span>失敗</span></div> */
</td>
</tr>
<tr>
<td></td>
<td>
<div>
<span class="pate-item">頁碼:<span #resultPage>1</span>/<span #resultTotalPage>1</span></span>
<button #resultPrev>上一頁</button>
<button #resultNext>下一頁</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</body>
</html>
<script type="text/tiscript">
function syncTasks() {
let res = view.getIndexingTasks()
let result = JSON.parse(res)
// 重置 #taskList
let tb = $(#taskList>tbody)
tb.html = ""
if (!result) {
return;
}
for (let i = 0; i < result.length; i++) {
let task = result[i];
let tr = new Element(#tr)
tr.append(new Element(#td, task.domain))
tr.append(new Element(#td, task.url_count + ""))
tr.append(new Element(#td, task.daily_finished + "/" + task.daily_num))
tr.append(new Element(#td, task.status + ""))
let td = new Element(#td)
td.@#class = "control-btns"
td.attributes["id"] = "task-" + task.id
addControlBtn(td, "結(jié)果", "task-result")
if (task.status == "running") {
addControlBtn(td, "停止", "task-stop")
} else {
addControlBtn(td, "啟動(dòng)", "task-start")
}
if (task.status != "running") {
addControlBtn(td, "編輯", "task-edit")
addControlBtn(td, "刪除", "task-delete")
}
tr.append(td)
tb.append(tr)
}
}
function addControlBtn(el, str, cls) {
let bt = new Element(#button, str)
bt.@#class = cls
el.append(bt)
}
self.on("click",".task-start", function() {
let id = this.$p(td).attributes['id'].replace("task-", "")
let result = view.startGoogleIndexing(id)
//view.msgbox(#alert, result || "啟動(dòng)成功");
});
self.on("click",".task-stop", function() {
let id = this.$p(td).attributes['id'].replace("task-", "")
let result = view.stopGoogleIndexing(id)
//view.msgbox(#alert, result || "停止成功");
});
self.on("click",".task-edit", function() {
let id = this.$p(td).attributes['id'].replace("task-", "")
showEditWindow(id)
});
self.on("click",".task-result", function() {
let id = this.$p(td).attributes['id'].replace("task-", "")
stdout.println(this.$p(td).attributes['id'])
showResultWindow(id, 1)
});
self.on("click",".task-delete", function() {
let id = this.$p(td).attributes['id'].replace("task-", "")
let result = view.deleteGoogleIndexing(id)
//view.msgbox(#alert, result || "刪除成功");
});
// 新建任務(wù)
event click $(#newTask){
showEditWindow("-1")
}
function showEditWindow(id) {
let res = view.getIndexingTask(id);
let result = JSON.parse(res) || {};
// 回填表單
$(#taslForm).value=result;
$(#taslForm).@.addClass("active");
}
// 表單
let accountPath = '';
event click $(#selectAccountJson){
let filePath = view.openAccountJson()
self#accountJson.text = filePath
accountPath = filePath;
}
event click $(#formClose){
$(#taslForm).@.removeClass("active");
}
event click $(#taskSubmit){
// 第一步,先保存授權(quán)信息
// 第二步,抓取Sitemap
// 第三步,開始推送
let result = view.createGoogleIndexing(accountPath, $(#taslForm).value.domain, $(#taslForm).value.daily_num)
stdout.println(result)
view.msgbox(#alert, result || "保存成功");
if (!result) {
$(#taslForm).@.removeClass("active");
}
// 同步結(jié)果
syncTasks();
}
let curId = 0;
let curPage = 1;
let totalPage = 1;
function showResultWindow(id, curp) {
curId = id;
let res = view.getIndexingTask(curId);
let result = JSON.parse(res) || {};
$(#resultList).@.addClass("active");
$(#resultDomain).text = result.domain;
$(#resultDailyNum).text = result.daily_num + "條";
$(#resultStatus).text = result.status;
$(#resultUrlCount).text = result.url_count + "條";
$(#resultDailyFinished).text = "累計(jì):" + result.total_finished + "條" + " / 今日:" + result.daily_finished + "條" + (result.daily_finished >= result.daily_num ? ' / 今日已完成' : '');
let res2 = view.getIndexingUrls(curId, curp)
let result2 = JSON.parse(res2) || {};
$(#resultPage).text = result2.page + "";
$(#resultTotalPage).text = result2.totalPage + "";
curPage = result2.page
totalPage = result2.totalPage
$(#resultResult).html = '';
for (let val in result2.urls) {
$(#resultResult).append("<div class='urls-item'><span class='item-url'>" + val.url + "</span> <span class='item-status' title='"+(val.msg || (val.status == 0 ? '未開始' :''))+"'>" + (val.status == 0 ? '-' : val.status != 200 ? "<span class='status-error'>"+val.status+"</span>" : val.status)+"</span></div>")
}
}
event click $(#resultPrev) {
if(curPage <= 1) {
curPage = 1;
return;
}
curPage = curPage - 1;
showResultWindow(curId, curPage);
}
event click $(#resultNext) {
if(curPage >= totalPage) {
curPage = totalPage;
return;
}
curPage = curPage + 1;
showResultWindow(curId, curPage);
}
event click $(.item-status) {
let title = this.attributes['title'];
if (title) {
view.msgbox(#error, title);
}
}
event click $(#resultClose){
$(#resultList).@.removeClass("active");
$(#taslForm).@.removeClass("active");
}
// 進(jìn)來的時(shí)候先執(zhí)行一遍
syncTasks();
// 加載tasklist,2秒鐘刷新一次
self.timer(2000ms, function() {
syncTasks();
return true;
});
</script>
使用幫助頁面 views/help.html
使用幫助頁面也是簡簡單單的HTML頁面。這里只用到了一處的JS代碼,用于調(diào)起系統(tǒng)瀏覽器,打開幫助文檔頁面。
<html resizeable>
<head>
<style src="home://views/style.css" />
<meta charSet="utf-8" />
</head>
<body>
<div class="layout">
<div class="aside">
<h1 class="soft-title"><a href="home://views/main.html">谷歌<br/>推送助手</a></h1>
<div class="aside-menus">
<a href="home://views/task.html" class="menu-item">推送任務(wù)</a>
<a href="home://views/help.html" class="menu-item active">使用教程</a>
</div>
</div>
<div class="container">
<div class="help-container">
<div><a #helpLink>訪問使用幫助頁面</a></div>
<div class="help-tips">注意:一定要認(rèn)真閱讀幫助頁面,每一個(gè)操作步驟都要細(xì)心按照教程執(zhí)行,注意截圖中的紅字,否則容易出錯(cuò)。</div>
</div>
</div>
</div>
</body>
</html>
<script type="text/tiscript">
event click $(#helpLink){
view.openUrl("https://www.anqicms.com/google-indexing-help.html")
}
</script>