Developing desktop client software has always been one of the common tasks for programmers, and Go language is increasingly used to develop various applications thanks to its concise, efficient and rich third-party libraries. Today we will combine Go language and HTML, using the Go binding library go-sciter of the open source project Sciter, to show you how to build a cross-platform desktop client with minimal development energy.

What is Sciter?

Sciter is a multi-platform embedded HTML/CSS/scripting engine suitable for building native desktop applications, and it performs very well. Through go-sciter, we can use Go to call the Sciter engine, and then use HTML, CSS, and JavaScript to create user interfaces and interact with Go's back-end logic.

Why choose Sciter?

  • Lightweight:Sciter is very lightweight and suitable for desktop applications that require quick builds.
  • Cross-platform:Supports Windows, macOS and Linux operating systems.
  • No third-party browser dependencies are required:Unlike Electron, Sciter does not need to rely on external browser engines, greatly reducing the size of the application.
  • Using modern front-end technology:Supports HTML5, CSS3 and JavaScript, and front-end developers can quickly get started.

Preparation

1. Install Go‌:

We default to think that you have Go installed. If it is not installed, you can download and install it from the official Go website. After the installation is complete, execute the following command to confirm whether Go is installed correctly:

go version

Notice
Because it is developed by CG, Windows users also need to install itmingw64-gcc.

2. Install the Sciter SDK

Go to the official Sciter website to download the Sciter SDK and select the version that suits your operating system (Windows, macOS or Linux). After decompression, place the dynamic library files (dll, so or dylib) in the bin directory into the system's environment variables, or store them with the executable file. For details, please refer to the official Sciter document.

Things to note
Since go-sciter has not been updated in time in the past two years, the latest Sciter SDK is not suitable for use, so you need to download it4.4.8The version of Sciter SDK is too new.

3. Install go-sciter

Install the go-sciter package through the Go command:

go get github.com/sciter-sdk/go-sciter

Start writing client programs

To facilitate the example, I will take a desktop application that I wrote casually as an example and expand the explanation.

To briefly explain, the function of this project is: automatically submit the URL of the website to the Google push server. The following is not a complete project code, because it also involves database operations, website site site site site sitemap retrieval, etc., so only important parts are listed.

First, create a project, let's name itgosciterBar. The process of creating a project will not be described in detail.

Sciter.dll orlibsciter.dylib(MacOS user) Put it in the project root directory.

If you need to use the sqlite database, you also need to copy sciter-sqlite.dll or sciter-sqlite.dylib. My project was used, so this file was copied.

Write 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"
)

// 为了让生成的可执行文件包含了界面文件,直接把views文件夹嵌入到可执行文件中

//go:embed all:views
var views embed.FS

// 定义一个Map类型的数据结构
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)
	}

  // 定义一个回调函数,用于处理加载资源,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
		},
	})

  // 这里定义一些与前端交互的函数
	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("网址填写错误")
	}
	// 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))
}

Write views/main.html

There is nothing special about the main page, it just uses a custom schemehome://

<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">推送任务</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>


Write views/task.html

The main task interface is performed here, including list rendering, page up and down, and button operations.

<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">推送任务</a>
            <a href="home://views/help.html" class="menu-item">使用教程</a>
        </div>
    </div>
    <div class="container">
        <div class="task-head">
            <button #newTask>新建任务</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>站点域名</th>
                        <th>URL数量</th>
                        <th>已推送/每日推送</th>
                        <th>状态</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>关闭</a>
                <h3>创建/编辑任务</h3>
            </div>
        <div class="form-content">
            <div class="form-item">
                <div class="form-label">网址或Sitemap地址:</div>
                <div class="input-block">
                    <input(domain) class="layui-input" type="text" placeholder="http://或https://开头的网站地址或Sitemap地址" />
                    <div class="text-muted">说明:如果填写了Sitemap地址,将自动获取Sitemap中的所有URL推送,<br/>否则将抓取推送网址下的所有链接。</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文件,用于授权。</div>
                </div>
            </div>
            <div class="form-item">
                <div class="form-label">每天推送数量:</div>
                <div class="input-block">
                    <input(daily_num) class="layui-input" type="text" placeholder="默认200" />
                    <div class="text-muted">说明:请根据你的接口限制,填写每天推送的量。</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>关闭</a>
                <h3>查看结果</h3>
            </div>
            <div class="form-content">
                <table>
                    <colgroup>
                        <col width="40%">
                        <col width="60%">
                    </colgroup>
                    <tbody>
                    <tr>
                        <td>网站网站</td>
                        <td #resultDomain></td>
                    </tr>
                    <tr>
                        <td>每日推送数量</td>
                        <td #resultDailyNum>0条</td>
                    </tr>
                    <tr>
                        <td>执行状态</td>
                        <td #resultStatus>waiting</td>
                    </tr>
                    <tr>
                        <td>已发现URL</td>
                        <td #resultUrlCount>0条</td>
                    </tr>
                    <tr>
                        <td>已推送</td>
                        <td #resultDailyFinished>0条</td>
                    </tr>
                    <tr>
                        <td>推送结果</td>
                        <td class="text-left" #resultResult>
                            /* <div><span></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, "结果", "task-result")
            if (task.status == "running") {
                addControlBtn(td, "停止", "task-stop")
            } else {
                addControlBtn(td, "启动", "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 || "启动成功");
    });
    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 || "删除成功");
    });
    // 新建任务
    event click $(#newTask){
        showEditWindow("-1")
    }

    function showEditWindow(id) {
        let res = view.getIndexingTask(id);
        let result = JSON.parse(res) || {};
        // 回填表单
        $(#taslForm).value=result;
        $(#taslForm)[email protected]("active");
    }

    // 表单
    let accountPath = '';
    event click $(#selectAccountJson){
        let filePath = view.openAccountJson()
        self#accountJson.text = filePath
        accountPath = filePath;
    }
    event click $(#formClose){
        $(#taslForm)[email protected]("active");
    }

    event click $(#taskSubmit){
        // 第一步,先保存授权信息
        // 第二步,抓取Sitemap
        // 第三步,开始推送
        let result = view.createGoogleIndexing(accountPath, $(#taslForm).value.domain, $(#taslForm).value.daily_num)
        stdout.println(result)
        view.msgbox(#alert, result || "保存成功");
        if (!result) {
            $(#taslForm)[email protected]("active");
        }
        // 同步结果
        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)[email protected]("active");

        $(#resultDomain).text = result.domain;
        $(#resultDailyNum).text = result.daily_num + "条";
        $(#resultStatus).text = result.status;
        $(#resultUrlCount).text = result.url_count + "条";
        $(#resultDailyFinished).text = "累计:" + 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)[email protected]("active");
        $(#taslForm)[email protected]("active");
    }
     // 进来的时候先执行一遍
    syncTasks();
    // 加载tasklist,2秒钟刷新一次
    self.timer(2000ms, function() {
        syncTasks();
        return true;
    });
</script>

Use the help page views/help.html

Using a help page is also a simple HTML page. Only one JS code is used here, which is used to adjust the system browser and open the help document page.

<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">推送任务</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">注意:一定要认真阅读帮助页面,每一个操作步骤都要细心按照教程执行,注意截图中的红字,否则容易出错。</div>
      </div>
    </div>
</div>

</body>
</html>
<script type="text/tiscript">
  event click $(#helpLink){
    view.openUrl("/google-indexing-help.html")
  }
</script>