我一直在将 Go 广泛用于我的独立初创公司的后端 REST 服务,但现在我需要一个桌面客户端,它可以与该服务交互,同时还可以在操作系统级别管理文件(符号链接、虚拟文件系统等)并促进用于数据传输的点对点连接。虽然 Go 非常适合这些用途,但它并不以擅长桌面或 GUI 而闻名。

对 GUI 有好处吗? (/r/golang)

这并不是因为该语言本身没有能力,而是根本没有很多可用的软件包(目前)可以让软件开发人员轻松或省时地构建 GUI,而他们只是想为客户构建并快速移动。

那么为什么不直接使用 Electron 之类的东西呢?

虽然我可能可以做出类似的工作并从所提供的 UI 功能中获益,但我的硬性要求(如下)更适合 Go。鉴于我的经验,我决定用 UI 来交换一些挑战,以换取轻松实现我的桌面关键功能。

- builds for Mac (dmg), Windows (exe), and Linux (deb)
- minimal (or no) 3rd party installations required for users
- authentication via 3rd party identity provider URL & callback
- runs as a tray application, launch custom windows for features
- manage files at the OS level; symlinks, mounts, virutal FS, etc.
- manage concurrent peer / remote connections for file sync
fyne

在您在本教程中采用我的方法之前,请先看看它们。

  • github.com/fyne-io/fyne

  • github.com/asticcode/go-astilectron

  • github.com/therecipe/qt

fynesystray

好的,足够的背景故事......让我们开始这个教程吧!

设置

本教程是为以下开发环境编写的:

amd64arm64

您可以在此处找到本教程的完整代码副本:

https://github.com/ctrlshiftmake/example-tray-gui

次安装
go mod init 
go get github.com/getlantern/systray
go get github.com/zserge/lorca
lorca

构建工具

我们将使用一些工具来设置构建过程,所以让我们安装它们。

brewnode / npm

brew.sh

nodejs.org

然后,使用这些命令安装以下软件包。

go get github.com/akavel/rsrc

npm install --global create-dmg

brew install graphicsmagick imagemagick

此外,我们需要 Docker 来为 Linux 构建,所以安装它。

docker.com/get-started

发展

步骤 1 - 托盘应用

systray

github.com/getlantern/systray/tree/master/e..

创建托盘图标

systray

github.com/getlantern/systray/blob/master/e..github.com/getlantern/systray/blob/master/e..

示例图标 PNG

iconicon.pngiconwin.goiconunix.go

如果您运行 windows bash 脚本,您还需要先将您的 PNG 转换为 ICO,您可以使用以下网站进行转换。您现在也可以创建它,因为它是构建所必需的。

cloudconvert.com/png-to-ico

基本托盘应用

trayOnReadyOnQuit
tray/tray.go
package tray 

func OnReady() {
    systray.SetIcon(icon.Data)

    mQuit := systray.AddMenuItem("Quit", "Quit example tray application")

    sigc := make(chan os.Signal, 1)
    signal.Notify(sigc, syscall.SIGTERM, syscall.SIGINT)

    for {
        select {
        case <-mQuit.ClickedCh:
            systray.Quit()
        case <-sigc:
            systray.Quit()
        }
    }

func OnQuit() {
}
main.go
func main() {
    systray.Run(tray.OnReady, tray.OnQuit)
}

我们现在有一个简单的托盘应用程序,它可以从菜单选项中退出,并且可以毫无问题地处理系统终止(例如:终端中的 Ctrl+C)。

在默认浏览器中启动Google.com(可选)

您可能希望有一些易于访问的菜单选项来将用户引导到桌面应用程序外部的 Web 应用程序或帮助台,因此让我们添加一个简单的菜单选项,以打开 Google 作为示例。

go get github.com/skratchdot/open-golang/open

然后用分隔线将新选项添加到我们的菜单中

tray/tray.go
package tray 

func OnReady() {
    systray.SetIcon(icon.Data)

    mGoogleBrowser := systray.AddMenuItem("Google in Browser", "Opens Google in a normal browser")
systray.AddSeparator()
    mQuit := systray.AddMenuItem("Quit", "Quit example tray application")

    sigc := make(chan os.Signal, 1)
    signal.Notify(sigc, syscall.SIGTERM, syscall.SIGINT)

    for {
        select {
        case <-mGoogleBrowser.ClickedCh:
            err := open.Run("https://www.google.com")
            if err != nil {
                fmt.Println(err)
            }
        case <-mQuit.ClickedCh:
            systray.Quit()
        case <-sigc:
            systray.Quit()
        }
    }

func OnQuit() {
}

第 2 步 - HTML5 视图

lorca
  • 维护我们创建的视图列表并提供打开它们的功能

  • 管理窗口状态,让你不能两次打开同一个窗口

  • 协助正常关闭打开的窗口和侦听器服务

单例模式在 Go 开发人员中存在争议,因为它会使测试等事情变得困难。值得考虑本教程之外的替代方案。

服务 www 和正常关机

WaitGroup
views/www/index.html
<!DOCTYPE html>
<html>
    <head>
        <title>Hello, World!</title>
    </head>
    <body>
        <h1>Hello, World!</h1>
    </body>
</html>
views/views.go
package views

const PORT = 8080
const HOST = "localhost"

var once sync.Once

//go:embed www
var fs embed.FS

type Views struct {
    WaitGroup *sync.WaitGroup
    Shutdown  chan bool
}

var views *Views

func Get() *Views {
    once.Do(func() {
        l := make(map[string]*View)

        views = &Views{
            WaitGroup: &sync.WaitGroup{},
            Shutdown:  make(chan bool),
        }

        views.WaitGroup.Add(1)
        go func(*Views) {
            defer views.WaitGroup.Done()
            ln, err := net.Listen("tcp", fmt.Sprintf("%s:%d", HOST, PORT))
            if err != nil {
                log.Fatal(err)
            }
            defer ln.Close()

            go func() {
                _ = http.Serve(ln, http.FileServer(http.FS(fs)))
            }()
            <-views.Shutdown
        }(views)
    })
    return views
}
views.Get()loalhost:8080wwwWaitGroupShutdownWaitGroup
tray/tray.go
...
func OnQuit() {
    close(views.Get().Shutdown)
}
main.go
func main() {
    views := views.Get()
    defer views.WaitGroup.Wait()
    systray.Run(tray.OnReady, tray.OnQuit)
}

你好,世界!

现在终于到了启动窗口的时候了!让我们将以下内容添加到视图中...

views/views.go
...

type View struct {
    url    string
    width  int
    height int
    isOpen bool
}

... 

func Get() *Views {
    once.Do(func() {
        l := make(map[string]*View)

        l["Hello"] = &View{
            url:    fmt.Sprintf("http://%s/www/index.html", fmt.Sprintf("%s:%d", HOST, PORT)),
            width:  600,
            height: 280,
        }

...
    })
    return views
}

func (v *Views) getView(name string) (*View, error) {
    view, ok := v.list[name]
    if !ok {
        return nil, fmt.Errorf("View '%s' not found", name)
    }
    if view.isOpen {
        return nil, fmt.Errorf("View is already open")
    }
    return view, nil
}

还有一个新功能可以打开我们的窗口......

views/view-index.go
func (v *Views) OpenIndex() error {
    view, err := v.getView("Hello")
    if err != nil {
        return err
    }

    v.WaitGroup.Add(1)
    go func(wg *sync.WaitGroup) {
        defer wg.Done()

        ui, err := lorca.New("", "", view.width, view.height)
        if err != nil {
            log.Fatal(err)
        }
        defer ui.Close()

        err = ui.Load(view.url)
        if err != nil {
            log.Fatal(err)
        }

        view.isOpen = true

        select {
        case <-ui.Done():
        case <-v.Shutdown:
        }

        view.isOpen = false

    }(v.WaitGroup)

    return nil
}
OnReady
tray/tray.go
func OnReady() {

...
    mHelloWorld := systray.AddMenuItem("Hello, World!", "Opens a simple HTML Hello, World")
...

    for {
        select {

        case <-mHelloWorld.ClickedCh:
            err := views.Get().OpenIndex()
            if err != nil {
                fmt.Println(err)
            }
        ...
        }
    }
}

绑定 Go 和 Javascript

lorca
config/config.go
package config

var (
    ApplicationVersion string = "development"
)

在本教程后面的构建应用程序时,我们将学习如何嵌入实际版本,但在本地运行时,它应该始终显示为“开发”。让我们现在更新索引页面...

views/www/index.html
<!DOCTYPE html>
<html>
    <head>
        <title>Hello, World!</title>
    </head>
    <body>
        <h1>Hello, World!</h1>
        <div>The application version is: <span id="version"></span></div>
        <script>
            const v = document.getElementById("version");
            const render = async () => {
                let appVersion = await window.appVersion();
                v.innerHTML = `<b>${appVersion}</b>`;
            };
            render();
        </script>
    </body>
</html>
appVersion()
views/view-index.go
type info struct {
    sync.Mutex
}

func (i *info) appVersion() string {
    i.Lock()
    defer i.Unlock()
    return config.ApplicationVersion
}

func (v *Views) OpenIndex() error {
    ...
    go func(wg *sync.WaitGroup) {
        ...
        defer ui.Close()

        i := info{}

        err = ui.Bind("appVersion", i.appVersion)
        if err != nil {
            log.Fatal(err)
        }

        err = ui.Load(view.url)
        ...
    }(v.WaitGroup)
    return nil
}

现在,下次您启动“Hello, World!”时你应该看到的窗口开发

第 3 步 - 跨平台构建

我们有一个系统托盘应用程序,它可以启动一个非常有用的窗口,现在我们想将它分发给我们在 Mac、Windows 和 Linux 上的用户。让我们设置我们的构建过程。

本教程不涉及代码签名,生成的 Mac 和 Windows 应用程序将要求用户允许它们运行。请仅将其视为构建过程的起点。

设置构建环境

.env.gitignore
.env
VERSION=1.0.0
NAME=ExampleTrayGUI
NAME_LOWER=example-tray-gui
Makefilemake buildmake run
Makefile
ifneq (,$(wildcard ./.env))
    include .env
    export
endif

ROOT=$(shell pwd)
-ldflagsVERSIONconfigApplicationVersion
build
build/flags.sh
#!/bin/sh
PKGCONFIG="github.com/ctrlshiftmake/example-tray-gui/config"

LD_FLAG_MESSAGE="-X '${PKGCONFIG}.ApplicationVersion=${VERSION}'"

LDFLAGS="${LD_FLAG_MESSAGE}"
VERSION

为 Mac 构建

icsnicon.icsnicons

512px 图标

cloudconvert.com/png-to-ico

plist
build/darwin/Info.plist
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>CFBundleExecutable</key>
    <string>ExampleTrayGUI</string>
    <key>CFBundleIconFile</key>
    <string>icon.icns</string>
    <key>CFBundleIdentifier</key>
    <string>com.Example.TrayGUI</string>
    <key>NSHighResolutionCapable</key>
    <string>True</string>
    <key>LSUIElement</key>
    <string>1</string>
    <key>CFBundleDisplayName</key>
    <string>Example TrayGUI</string>
</dict>
</plist>

现在是实际的构建说明,它将为我们执行以下操作:

binldflagsplisticon.icnscreate-dmgbin
build/build-darwin.sh
#!/bin/sh
source flags.sh

APP="${NAME}.app"
BUILD_DIR="../bin/${VERSION}/"

rm -rf ${BUILD_DIR}/"$APP"/
mkdir -p ${BUILD_DIR}/"$APP"/Contents/{MacOS,Resources}

GOOS=darwin GOARCH=amd64 go build -o ${BUILD_DIR}/"$APP"/Contents/MacOS/${NAME} -ldflags="${LDFLAGS}" ../main.go

cp ./darwin/Info.plist ${BUILD_DIR}/"${APP}"/Contents/Info.plist
cp ../icon/icon.icns ${BUILD_DIR}/"${APP}"/Contents/Resources/icon.icns

cd ${BUILD_DIR}

rm *.dmg
create-dmg --dmg-title="${NAME}" --overwrite "${APP}"
mv *.dmg ${NAME}_${VERSION}_mac_amd64.dmg
rm -rf "${APP}"
Makefile
build-darwin:
    source .env && cd build; sh build-darwin.sh
make build-darwinbin.gitignore/bin

为 Windows 构建

iconwin.icoicon
manifest.syso
build/windows/ExampleTrayGUI.exe.manifest
<?xml version="1.0" encoding="UTF-8" standalone="yes" ?>
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
  <assemblyIdentity
    type="win32"
    name="Github.com.ctrlshiftmake.ExampleTrayGUI"
    version="1.0.0.0"
    processorArchitecture="*"
  />
 <description>ExampleTrayGUI</description>
 <trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
   <security>
     <requestedPrivileges>
       <requestedExecutionLevel level="asInvoker" uiAccess="false" />
       </requestedPrivileges>
   </security>
 </trustInfo>
</assembly>

现在让我们创建构建指令,它将为我们执行以下操作:

bin.sysoldflagsditto
#!/bin/sh
source flags.sh

APP="${NAME}.exe"
LDFLAGS="${LDFLAGS} -H windowsgui"
BUILD_DIR="../bin/${VERSION}/"

rm -rf ${BUILD_DIR}/"${APP}"

rsrc -arch amd64 -ico ../icon/iconwin.ico -manifest "./windows/ExampleTrayGUI.exe.manifest" -o ../ExampleTrayGUI.syso

GOOS=windows GOARCH=amd64 go build -o ${BUILD_DIR}/"${APP}" -ldflags="${LDFLAGS}" ../main.go

ditto -c -k --sequesterRsrc ${BUILD_DIR}/"${APP}" ${BUILD_DIR}/${NAME}_${VERSION}_windows_amd64.zip

rm -rf ${BUILD_DIR}/"${APP}"
rm -rf ../ExampleTrayGUI.syso

最后,让我们在 Makefile 中添加一个命令

build-windows:
    source .env && cd build; sh build-windows.sh

为 Linux (ubuntu) 构建

systray
Dockerfile.env.shbin
systray
Dockefile
build/linux/Dockerfile
FROM ubuntu

ENV GO111MODULE=on \
    CGO_ENABLED=1 \
    GOOS=linux \
    GOARCH=amd64

ARG DEBIAN_FRONTEND=noninteractive

RUN apt-get update && apt-get install -y sudo wget

RUN useradd -m docker && echo "docker:docker" | chpasswd && adduser docker sudo

RUN wget -c https://dl.google.com/go/go1.16.4.linux-amd64.tar.gz -O - | sudo tar -xz -C /usr/local
ENV PATH="/usr/local/go/bin:${PATH}"

RUN apt-get install -y wget gcc libgtk-3-dev libappindicator3-dev make

WORKDIR /example-tray-gui
COPY ../go.mod .
COPY ../go.sum .
RUN go mod download

COPY ../ .
.sh.deb
#!/bin/bash
source flags.sh

APP=${NAME_LOWER}
APPDIR=../bin/${VERSION}/${APP}

mkdir -p $APPDIR/usr/bin
mkdir -p $APPDIR/usr/share/applications
mkdir -p $APPDIR/usr/share/icons/hicolor/1024x1024/apps
mkdir -p $APPDIR/usr/share/icons/hicolor/256x256/apps
mkdir -p $APPDIR/DEBIAN

CC="gcc" CGO_ENABLED=1 GOOS=linux GOARCH=amd64 go build -o $APPDIR/usr/bin/$APP -ldflags="${LDFLAGS}" ../main.go

cp ../icon/icon.png $APPDIR/usr/share/icons/hicolor/1024x1024/apps/${APP}.png
cp ../icon/icon.png $APPDIR/usr/share/icons/hicolor/256x256/apps/${APP}.png

cat > $APPDIR/usr/share/applications/${APP}.desktop << EOF
[Desktop Entry]
Version=${VERSION}
Type=Application
Name=$APP
Exec=$APP
Icon=$APP
Terminal=false
StartupWMClass=ExampleTrayGUI
EOF

cat > $APPDIR/DEBIAN/control << EOF
Package: ${APP}
Version: ${VERSION}
Section: base
Priority: optional
Architecture: amd64
Maintainer: Grant Moore <grantmoore3d@gmail.com>
Description: Example Tray GUI Application
EOF

dpkg-deb --build $APPDIR
mv ${APPDIR}.deb ../bin/${VERSION}/${NAME}_${VERSION}_linux_amd64.deb
rm -rf ${APPDIR}

最后,将以下命令添加到我们的 Makefile 中,这将准备 Docker 实例、为 Linux 构建和清理映像。

build-linux: docker-build-linux docker-clean

docker-build-linux:
    docker build -t example-tray-gui -f build/linux/Dockerfile .
    docker run -v $(ROOT)/bin:/example-tray-gui/bin -t example-tray-gui bash -c 'export VERSION=${VERSION} && export NAME=${NAME} && export NAME_LOWER=${NAME_LOWER} && cd build; bash build-linux.sh'

docker-clean:
    docker rm $(shell docker ps --all -q)
    docker rmi $(shell docker images | grep example-tray-gui | tr -s ' ' | cut -d ' ' -f 3)
make build-linuxdeb

第一次运行此命令时,Docker 可能需要一段时间来准备映像。后续运行会快得多,因为它会在您的机器上缓存大量初始映像构建过程。

建造一切

build
ifneq (,$(wildcard ./.env))
    include .env
    export
endif

ROOT=$(shell pwd)

run:
    go run main.go

build: build-darwin build-windows build-linux

build-darwin:
    source .env && cd build; sh build-darwin.sh

build-windows:
    source .env && cd build; sh build-windows.sh

build-linux: docker-build-linux docker-clean

docker-build-linux:
    docker build -t example-tray-gui -f build/linux/Dockerfile .
    docker run -v $(ROOT)/bin:/example-tray-gui/bin -t example-tray-gui bash -c 'export VERSION=${VERSION} && export NAME=${NAME} && export NAME_LOWER=${NAME_LOWER} && cd build; bash build-linux.sh'

docker-clean:
    docker rm $(shell docker ps --all -q)
    docker rmi $(shell docker images | grep example-tray-gui | tr -s ' ' | cut -d ' ' -f 3)

最后的想法

为了简洁起见,本教程中没有描述许多细微的细节,但我希望这可以作为您自己的应用程序的一个良好起点。如果您不确定,请务必检查完成的存储库,因为它是我的真实来源,绝不会将其视为完美的代码。

https://github.com/ctrlshiftmake/example-tray-gui

我希望这对您有所帮助,享受在 Go 中构建您的托盘/GUI 桌面应用程序!