这是所有开发人员都问过自己的问题,尤其是来自 webdev 世界的开发人员:“如果我可以运行几乎任何可以在浏览器中呈现的内容并满足我想要的几乎任何目的,谁需要下载我们的应用程序并在他们的计算机上运行它?”。但是,除了我们正在做的工作的明显要求(对于我们自己或公司来说,例如能够利用所有操作系统功能、更好的性能、离线功能、改进的安全性和集成等),还有作为开发人员,我们从接触编程的新方面中获得的经验将永远丰富我们。
如果你像我一样对 Golang 充满热情,并且你已经用这种语言开发过后端,但你也使用 HTML、CSS 和 JavaScript(或其一些框架)做过前端,那么这篇文章适合你,因为不需要要学习新技术,您完全有能力创建桌面应用程序。
您可能已经知道电子或Tauri。两者都使用 Web 技术作为前端;第一个在其后端使用 JavaScript(或者更确切地说,NodeJs),第二个使用 Rust。但两者或多或少都有明显的缺点。 Electron 应用程序具有非常大的二进制文件(因为它们打包了整个 Chromium 浏览器)并消耗大量内存。 Tauri 应用程序改进了这些方面(除其他外,因为它们使用 WebView2 [Windows]/WebKit [macOS 和 Linux] 而不是 Chromium),但二进制文件仍然相对较大,并且它们的编译时间是……是 Rust 的编译时间吗? (更不用说他们的学习曲线,虽然我喜欢 Rust,但我是认真的?)。
通过使用 Wails,您可以利用我刚才描述的 Web 技术充分利用桌面应用程序开发的所有领域,以及使用 Go 带来的所有优势:
是的,如果我想使用 Go 创建桌面应用程序,还有其他可能性(原生或非原生)。我会提到 Fyne 和 go-gtk。 Fyne 是一个 GUI 框架,允许轻松创建本机应用程序,尽管它们可能具有优雅的设计,但该框架的功能有些有限,或者需要开发人员付出巨大的努力才能实现与其他工具和/或语言可以让您轻松完成。我可以对 go-gtk 说同样的话,它是 GTK 的 Go 绑定:是的,确实,您将获得本机应用程序,其限制将取决于您自己的能力,但是进入 GTK 库就像去丛林探险一样?…
首先,对于那些想知道Nu-i uita 含义的人:在罗马尼亚语中,它的大致意思是“不要忘记他们”。我还以为是原来的名字...
您可以在此 GitHub 存储库中查看应用程序的完整代码。如果您想立即尝试,可以从此处下载可执行文件(适用于 Windows 和 Linux)。
我将简要描述该应用程序的工作原理:用户首次登录,登录窗口要求他输入主密码。这是使用密码本身作为加密密钥加密保存的。此登录窗口通向另一个界面,用户可以在其中列出相应网站的保存密码和使用的用户名(您也可以在此列表中按用户名或网站进行搜索)。您可以单击列表中的每个项目并查看其详细信息、将其复制到剪贴板、编辑或删除。此外,当添加新项目时,它会使用主密码作为密钥来加密您的密码。在配置窗口中,您可以选择所需的语言(当前只有英语和西班牙语)、删除所有存储的数据、导出或从备份文件导入。导入数据时,系统将要求用户提供执行导出时使用的主密码,导入的数据现在将使用当前主密码保存和加密。随后,每当用户重新登录应用程序时,系统都会提示他或她输入当前的主密码。
我不会详细讨论使用 Wails 所需的要求,因为它在其出色的文档中得到了很好的解释。无论如何,安装其强大的 CLI(去安装 github.com/wailsapp/wails/v2/cmd/wails@latest)是至关重要的,它允许您为应用程序生成脚手架,在编辑代码时进行热重载,并构建可执行文件(包括交叉编译)。
Wails CLI 允许您使用各种前端框架生成项目,但出于某种原因,Wails 的创建者似乎更喜欢 Svelte...因为这是他们提到的第一个选项。当您使用命令 wails init -n myproject -t svelte-ts 时,您会生成一个带有 Svelte3 和 TypeScript.
的项目如果出于某种原因您更喜欢使用 Svelte5 及其新的 runes 系统 功能,我创建了一个 bash 脚本,它可以自动生成项目苗条5。在这种情况下,您还需要安装 Wails CLI。
我上面提到的应用程序的功能构成了任何todoapp的要求(这始终是学习编程新知识的好方法),但这里我们添加一个加上使其更有用的功能(例如,在后端使用对称加密,在前端使用国际化)比简单的待办事项应用程序更有启发性。
好了,介绍已经够多了,那么我们开始进入正题吧?.
如果您选择通过 CLI 运行命令 wails init -n myproject -t svelte-ts (或者使用我创建的 bash 脚本,并且我之前已经告诉过您)来创建带有 CLI 的 Svelte Typescript 的 Wails 项目,则会生成使用 Svelte5 进行 Wails 项目)您将拥有与此非常相似的目录结构:
. ├── app.go ├── build │ ├── appicon.png │ ├── darwin │ │ ├── Info.dev.plist │ │ └── Info.plist │ ├── README.md │ └── windows │ ├── icon.ico │ ├── info.json │ ├── installer │ │ ├── project.nsi │ │ └── wails_tools.nsh │ └── wails.exe.manifest ├── frontend │ ├── index.html │ ├── package.json │ ├── package.json.md5 │ ├── package-lock.json │ ├── postcss.config.js │ ├── README.md │ ├── src │ │ ├── App.svelte │ │ ├── assets │ │ │ ├── fonts │ │ │ │ ├── nunito-v16-latin-regular.woff2 │ │ │ │ └── OFL.txt │ │ │ └── images │ │ │ └── logo-universal.png │ │ ├── lib │ │ │ ├── BackBtn.svelte │ │ │ ├── BottomActions.svelte │ │ │ ├── EditActions.svelte │ │ │ ├── EntriesList.svelte │ │ │ ├── Language.svelte │ │ │ ├── popups │ │ │ │ ├── alert-icons.ts │ │ │ │ └── popups.ts │ │ │ ├── ShowPasswordBtn.svelte │ │ │ └── TopActions.svelte │ │ ├── locales │ │ │ ├── en.json │ │ │ └── es.json │ │ ├── main.ts │ │ ├── pages │ │ │ ├── About.svelte │ │ │ ├── AddPassword.svelte │ │ │ ├── Details.svelte │ │ │ ├── EditPassword.svelte │ │ │ ├── Home.svelte │ │ │ ├── Login.svelte │ │ │ └── Settings.svelte │ │ ├── style.css │ │ └── vite-env.d.ts │ ├── svelte.config.js │ ├── tailwind.config.js │ ├── tsconfig.json │ ├── tsconfig.node.json │ ├── vite.config.ts │ └── wailsjs │ ├── go │ │ ├── main │ │ │ ├── App.d.ts │ │ │ └── App.js │ │ └── models.ts │ └── runtime │ ├── package.json │ ├── runtime.d.ts │ └── runtime.js ├── go.mod ├── go.sum ├── internal │ ├── db │ │ └── db.go │ └── models │ ├── crypto.go │ ├── master_password.go │ └── password_entry.go ├── LICENSE ├── main.go ├── Makefile ├── README.md ├── scripts └── wails.json
您刚刚看到的是完成的应用程序结构。与 Wails CLI 生成的唯一区别是,使用它,您将获得具有 Svelte3 TypeScript 前端的 Wails 应用程序的脚手架,而使用我的脚本,除了 Svelte5 之外,Tailwindcss Daisyui 是集成。
但是让我们看看 Wails 应用程序的一般工作原理,同时详细说明我们的案例:
正如 Wails 文档所述:“Wails 应用程序是一个标准的 Go 应用程序,具有 webkit 前端。应用程序的 Go 部分由应用程序代码和 运行时库 组成它提供了许多有用的操作,例如控制应用程序窗口。前端是一个将显示前端资源的 webkit 窗口。简而言之,正如我们可能已经知道的那样,如果我们使用 Web 技术创建了桌面应用程序,那么非常简单地解释一下,该应用程序由一个后端(在我们的例子中用 Go 编写)和一个前端组成,前端的资产由 Webkit 窗口(在以 Windows 操作系统为例,Webview2),类似于服务/渲染前端资产的 Web 服务器/浏览器的本质。
在我们的具体案例中,我们希望应用程序能够在 Windows 和 Linux 上运行,主应用程序由以下代码组成:
/* main.go */ package main import ( "embed" "github.com/wailsapp/wails/v2" "github.com/wailsapp/wails/v2/pkg/options" "github.com/wailsapp/wails/v2/pkg/options/assetserver" "github.com/wailsapp/wails/v2/pkg/options/linux" ) //go:embed all:frontend/dist var assets embed.FS //go:embed build/appicon.png var icon []byte func main() { // Create an instance of the app structure app := NewApp() // Create application with options err := wails.Run(&options.App{ // Title: "Nu-i uita • minimalist password manager", Width: 450, Height: 300, DisableResize: true, AssetServer: &assetserver.Options{ Assets: assets, }, BackgroundColour: &options.RGBA{R: 27, G: 38, B: 54, A: 1}, OnStartup: app.startup, OnBeforeClose: app.beforeClose, Bind: []interface{}{ app, }, // Linux platform specific options Linux: &linux.Options{ Icon: icon, // WindowIsTranslucent: true, WebviewGpuPolicy: linux.WebviewGpuPolicyNever, // ProgramName: "wails", }, }) if err != nil { println("Error:", err.Error()) } }
我们需要做的第一件事是实例化一个结构体(使用 NewApp 函数),我们同意将其称为 App,它必须有一个带有 Go Context 的字段。然后,wails 的 Run 方法是启动应用程序的方法。我们需要向它传递一系列选项。这些强制性选项之一是资产。一旦 Wails 编译了前端,它就会在“frontend/dist”文件夹中生成它。使用 //go:embed all:frontend/dist 指令(Go 的神奇功能),我们可以将整个前端嵌入到最终的可执行文件中。对于Linux,如果我们想嵌入应用程序图标,我们还必须使用//go:embed指令。
其余选项我不会详细介绍,您可以在文档中查看。我只想说两件与选项相关的事情。第一个是出现在应用程序标题栏中的标题可以在这里设置为一个选项,但是在我们的应用程序中,用户可以选择他们想要的语言,当我们收到用户可能进行的语言更改事件。我们稍后会看到这个。
第二个重要的选项相关问题是绑定选项。文档很好地解释了它的含义:“Bind 选项是 Wails 应用程序中最重要的选项之一。它指定向前端公开哪些结构方法。想想传统 Web 中的 controllers 这样的结构应用。”确实:App 结构的公共方法,即那些将后端暴露给前端的方法,发挥了 Go 与 JavaScript“连接”的魔力。所述结构体的那些公共方法被转换为 JavaScript 函数,并通过 Wails 执行的编译返回一个 Promise。
后端和前端之间的另一种重要通信形式(我们在此应用程序中有效使用)是事件。 Wails 提供了一个事件系统,其中事件可以由 Go 或 JavaScript 发出或接收。或者,数据可以与事件一起传递。研究我们在应用程序中使用事件的方式将引导我们分析 struct App:
. ├── app.go ├── build │ ├── appicon.png │ ├── darwin │ │ ├── Info.dev.plist │ │ └── Info.plist │ ├── README.md │ └── windows │ ├── icon.ico │ ├── info.json │ ├── installer │ │ ├── project.nsi │ │ └── wails_tools.nsh │ └── wails.exe.manifest ├── frontend │ ├── index.html │ ├── package.json │ ├── package.json.md5 │ ├── package-lock.json │ ├── postcss.config.js │ ├── README.md │ ├── src │ │ ├── App.svelte │ │ ├── assets │ │ │ ├── fonts │ │ │ │ ├── nunito-v16-latin-regular.woff2 │ │ │ │ └── OFL.txt │ │ │ └── images │ │ │ └── logo-universal.png │ │ ├── lib │ │ │ ├── BackBtn.svelte │ │ │ ├── BottomActions.svelte │ │ │ ├── EditActions.svelte │ │ │ ├── EntriesList.svelte │ │ │ ├── Language.svelte │ │ │ ├── popups │ │ │ │ ├── alert-icons.ts │ │ │ │ └── popups.ts │ │ │ ├── ShowPasswordBtn.svelte │ │ │ └── TopActions.svelte │ │ ├── locales │ │ │ ├── en.json │ │ │ └── es.json │ │ ├── main.ts │ │ ├── pages │ │ │ ├── About.svelte │ │ │ ├── AddPassword.svelte │ │ │ ├── Details.svelte │ │ │ ├── EditPassword.svelte │ │ │ ├── Home.svelte │ │ │ ├── Login.svelte │ │ │ └── Settings.svelte │ │ ├── style.css │ │ └── vite-env.d.ts │ ├── svelte.config.js │ ├── tailwind.config.js │ ├── tsconfig.json │ ├── tsconfig.node.json │ ├── vite.config.ts │ └── wailsjs │ ├── go │ │ ├── main │ │ │ ├── App.d.ts │ │ │ └── App.js │ │ └── models.ts │ └── runtime │ ├── package.json │ ├── runtime.d.ts │ └── runtime.js ├── go.mod ├── go.sum ├── internal │ ├── db │ │ └── db.go │ └── models │ ├── crypto.go │ ├── master_password.go │ └── password_entry.go ├── LICENSE ├── main.go ├── Makefile ├── README.md ├── scripts └── wails.json
我们首先看到的是 struct App,它有一个字段,用于存储 Wails 所需的 Go Context,以及一个指向 struct Db 的指针(与数据库相关,我们将看到) 。另外 2 个属性是我们配置的字符串,以便本机对话框(由后端管理)根据用户选择的语言显示标题。充当 App 的构造函数 (NewApp) 的函数只是创建指向数据库结构的指针。
接下来我们看到Wails需要的选项需要2个方法:startup和beforeClose,我们将分别传递给OnStartup和 OnBeforeClose 选项。通过这样做,他们将自动收到 Go Context。 beforeClose 只是在关闭应用程序时关闭与数据库的连接。但初创公司做得更多。首先,它设置在其相应字段中接收到的上下文。其次,它在后端注册一系列事件监听器,我们需要触发一系列操作。
在Wails中,所有事件监听器都有这个签名:
. ├── app.go ├── build │ ├── appicon.png │ ├── darwin │ │ ├── Info.dev.plist │ │ └── Info.plist │ ├── README.md │ └── windows │ ├── icon.ico │ ├── info.json │ ├── installer │ │ ├── project.nsi │ │ └── wails_tools.nsh │ └── wails.exe.manifest ├── frontend │ ├── index.html │ ├── package.json │ ├── package.json.md5 │ ├── package-lock.json │ ├── postcss.config.js │ ├── README.md │ ├── src │ │ ├── App.svelte │ │ ├── assets │ │ │ ├── fonts │ │ │ │ ├── nunito-v16-latin-regular.woff2 │ │ │ │ └── OFL.txt │ │ │ └── images │ │ │ └── logo-universal.png │ │ ├── lib │ │ │ ├── BackBtn.svelte │ │ │ ├── BottomActions.svelte │ │ │ ├── EditActions.svelte │ │ │ ├── EntriesList.svelte │ │ │ ├── Language.svelte │ │ │ ├── popups │ │ │ │ ├── alert-icons.ts │ │ │ │ └── popups.ts │ │ │ ├── ShowPasswordBtn.svelte │ │ │ └── TopActions.svelte │ │ ├── locales │ │ │ ├── en.json │ │ │ └── es.json │ │ ├── main.ts │ │ ├── pages │ │ │ ├── About.svelte │ │ │ ├── AddPassword.svelte │ │ │ ├── Details.svelte │ │ │ ├── EditPassword.svelte │ │ │ ├── Home.svelte │ │ │ ├── Login.svelte │ │ │ └── Settings.svelte │ │ ├── style.css │ │ └── vite-env.d.ts │ ├── svelte.config.js │ ├── tailwind.config.js │ ├── tsconfig.json │ ├── tsconfig.node.json │ ├── vite.config.ts │ └── wailsjs │ ├── go │ │ ├── main │ │ │ ├── App.d.ts │ │ │ └── App.js │ │ └── models.ts │ └── runtime │ ├── package.json │ ├── runtime.d.ts │ └── runtime.js ├── go.mod ├── go.sum ├── internal │ ├── db │ │ └── db.go │ └── models │ ├── crypto.go │ ├── master_password.go │ └── password_entry.go ├── LICENSE ├── main.go ├── Makefile ├── README.md ├── scripts └── wails.json
也就是说,它接收上下文(我们在 App 的 ctx 字段中保存的上下文)、事件名称(我们将在前端建立)以及将执行的回调我们需要的操作,并且它又可以接收类型为 any 或空接口 (interface{}) 的可选参数,这是相同的,因此我们必须进行类型断言。
我们声明的一些侦听器在其中声明了嵌套事件发射器,这些事件发射器将在前端接收并在那里触发某些操作。他的签名如下所示:
/* main.go */ package main import ( "embed" "github.com/wailsapp/wails/v2" "github.com/wailsapp/wails/v2/pkg/options" "github.com/wailsapp/wails/v2/pkg/options/assetserver" "github.com/wailsapp/wails/v2/pkg/options/linux" ) //go:embed all:frontend/dist var assets embed.FS //go:embed build/appicon.png var icon []byte func main() { // Create an instance of the app structure app := NewApp() // Create application with options err := wails.Run(&options.App{ // Title: "Nu-i uita • minimalist password manager", Width: 450, Height: 300, DisableResize: true, AssetServer: &assetserver.Options{ Assets: assets, }, BackgroundColour: &options.RGBA{R: 27, G: 38, B: 54, A: 1}, OnStartup: app.startup, OnBeforeClose: app.beforeClose, Bind: []interface{}{ app, }, // Linux platform specific options Linux: &linux.Options{ Icon: icon, // WindowIsTranslucent: true, WebviewGpuPolicy: linux.WebviewGpuPolicyNever, // ProgramName: "wails", }, }) if err != nil { println("Error:", err.Error()) } }
我不会详细介绍这些侦听器的作用,不仅是为了简洁,还因为 Go 的表达能力足够强,您只需阅读代码就可以知道它们的作用。我只解释其中的几个。 “change_titles”侦听器期望接收具有该名称的事件。当用户更改界面语言,将应用程序窗口标题栏的标题更改为侦听器本身接收到的值时,会触发此事件。我们使用Wails运行时包来实现这一点。该事件还接收“选择目录”和“选择文件”对话框的标题,这些标题存储在 App 结构的单独属性中,以便在需要时使用。如您所见,我们需要此事件,因为这些“本机”操作需要从后端执行。
特别值得一提的是听众“import_data”和“password”,可以说,链接。第一个(“import_data”)在收到时会触发使用 runtime.OpenFileDialog 方法打开对话框。正如我们所看到的,该方法在其选项中接收要显示的标题,该标题存储在 App 结构的 selectedFile 字段中,正如我们已经解释的那样。如果用户选择一个文件,因此 fileLocation 变量不为空,则会发出一个事件(称为“enter_password”),该事件在前端接收,以显示一个弹出窗口,要求用户输入他所设置的主密码。他在出口时使用。当用户这样做时,前端会发出一个事件(“密码”),我们在后端侦听器中接收该事件。接收到的数据(主密码)和备份文件的路径由代表数据库(ImportDump)的 Db 结构的方法使用。根据所述方法的执行结果,会发出一个新事件(“imported_data”),该事件将在前端触发一个弹出窗口,显示导入成功或失败的结果。
正如我们所见,Wails 事件是后端和前端之间一种强大且有效的通信方式。
App 结构体的其余方法只不过是后端向前端公开的方法,正如我们已经解释过的,这些方法基本上是对数据库的 CRUD 操作,因此,我们在下面进行解释。
对于这个后端部分,我受到 vikkio88 的这篇文章(在 DEV.to 上)和他的密码管理器存储库的启发(做了一些修改),他首先使用 C#/Avalonia 创建了该密码管理器,然后改编为使用 Go/费恩 (麝香-ig)。
后端的“最低级别”部分是与密码加密相关的部分。最重要的是这 3 个功能:
. ├── app.go ├── build │ ├── appicon.png │ ├── darwin │ │ ├── Info.dev.plist │ │ └── Info.plist │ ├── README.md │ └── windows │ ├── icon.ico │ ├── info.json │ ├── installer │ │ ├── project.nsi │ │ └── wails_tools.nsh │ └── wails.exe.manifest ├── frontend │ ├── index.html │ ├── package.json │ ├── package.json.md5 │ ├── package-lock.json │ ├── postcss.config.js │ ├── README.md │ ├── src │ │ ├── App.svelte │ │ ├── assets │ │ │ ├── fonts │ │ │ │ ├── nunito-v16-latin-regular.woff2 │ │ │ │ └── OFL.txt │ │ │ └── images │ │ │ └── logo-universal.png │ │ ├── lib │ │ │ ├── BackBtn.svelte │ │ │ ├── BottomActions.svelte │ │ │ ├── EditActions.svelte │ │ │ ├── EntriesList.svelte │ │ │ ├── Language.svelte │ │ │ ├── popups │ │ │ │ ├── alert-icons.ts │ │ │ │ └── popups.ts │ │ │ ├── ShowPasswordBtn.svelte │ │ │ └── TopActions.svelte │ │ ├── locales │ │ │ ├── en.json │ │ │ └── es.json │ │ ├── main.ts │ │ ├── pages │ │ │ ├── About.svelte │ │ │ ├── AddPassword.svelte │ │ │ ├── Details.svelte │ │ │ ├── EditPassword.svelte │ │ │ ├── Home.svelte │ │ │ ├── Login.svelte │ │ │ └── Settings.svelte │ │ ├── style.css │ │ └── vite-env.d.ts │ ├── svelte.config.js │ ├── tailwind.config.js │ ├── tsconfig.json │ ├── tsconfig.node.json │ ├── vite.config.ts │ └── wailsjs │ ├── go │ │ ├── main │ │ │ ├── App.d.ts │ │ │ └── App.js │ │ └── models.ts │ └── runtime │ ├── package.json │ ├── runtime.d.ts │ └── runtime.js ├── go.mod ├── go.sum ├── internal │ ├── db │ │ └── db.go │ └── models │ ├── crypto.go │ ├── master_password.go │ └── password_entry.go ├── LICENSE ├── main.go ├── Makefile ├── README.md ├── scripts └── wails.json
我不会详细介绍 Go 中使用 AES 进行对称加密的细节。您需要了解的一切都在 DEV.to 的这篇文章中得到了很好的解释。
AES 是一种分组密码算法,它采用固定大小的密钥和固定大小的明文,并返回固定大小的密文。由于 AES 的块大小设置为 16 字节,因此明文长度必须至少为 16 字节。这给我们带来了一个问题,因为我们希望能够加密/解密任意大小的数据。为了解决最小明文块大小的问题,存在分组密码模式。这里我使用 GCM 模式,因为它是最广泛采用的对称分组密码模式之一。 GCM 需要一个 IV(初始化向量 [数组]),它必须始终随机生成(用于此类数组的术语是随机数)。
基本上,加密 函数采用明文进行加密,并使用始终为 32 字节长的密钥,并使用该密钥生成 AES 加密器。使用该加密器,我们生成一个 gcm 对象,用于创建 12 字节初始化向量 (nonce)。 gcm 对象的 Seal 方法允许我们使用向量 nonce “连接”并加密明文(作为字节切片),最后将其结果转换回字符串。
decrypt函数的作用相反:它的第一部分等于加密,那么由于我们知道密文实际上是nonce密文,所以我们可以将密文拆分为它的两个组成部分。 gcm 对象的 NonceSize 方法总是产生“12”(这是随机数的长度),因此我们在解密的同时分割字节切片使用 gcm 对象的 Open 方法。最后,我们将结果转换为字符串。
keyfy 函数确保我们拥有一个 32 字节的密钥(通过用“0”填充以达到该长度)。我们将看到,在前端,我们确保用户不会输入超过 1 个字节的字符(非 ASCII 字符),因此该函数的结果始终为 32 个字节长。
此文件中的其余代码本质上负责将上述函数的输入/输出编码/解码为 base64。
为了存储所有应用程序数据,我们使用cloverDB。它是一个轻量级、嵌入式的面向文档的NoSQL数据库,类似于MongoDB。该数据库的特点之一是,当保存记录时,会为它们分配一个 ID(默认情况下,该字段指定为 _id,有点像 MongoDB 中的情况),这是一个 uuid 字符串( v4)。因此,如果我们想按条目顺序对记录进行排序,则必须在存储它们时为其分配时间戳。
基于这些事实,我们将创建我们的模型及其相关方法(master_password.go 和password_entry.go):
. ├── app.go ├── build │ ├── appicon.png │ ├── darwin │ │ ├── Info.dev.plist │ │ └── Info.plist │ ├── README.md │ └── windows │ ├── icon.ico │ ├── info.json │ ├── installer │ │ ├── project.nsi │ │ └── wails_tools.nsh │ └── wails.exe.manifest ├── frontend │ ├── index.html │ ├── package.json │ ├── package.json.md5 │ ├── package-lock.json │ ├── postcss.config.js │ ├── README.md │ ├── src │ │ ├── App.svelte │ │ ├── assets │ │ │ ├── fonts │ │ │ │ ├── nunito-v16-latin-regular.woff2 │ │ │ │ └── OFL.txt │ │ │ └── images │ │ │ └── logo-universal.png │ │ ├── lib │ │ │ ├── BackBtn.svelte │ │ │ ├── BottomActions.svelte │ │ │ ├── EditActions.svelte │ │ │ ├── EntriesList.svelte │ │ │ ├── Language.svelte │ │ │ ├── popups │ │ │ │ ├── alert-icons.ts │ │ │ │ └── popups.ts │ │ │ ├── ShowPasswordBtn.svelte │ │ │ └── TopActions.svelte │ │ ├── locales │ │ │ ├── en.json │ │ │ └── es.json │ │ ├── main.ts │ │ ├── pages │ │ │ ├── About.svelte │ │ │ ├── AddPassword.svelte │ │ │ ├── Details.svelte │ │ │ ├── EditPassword.svelte │ │ │ ├── Home.svelte │ │ │ ├── Login.svelte │ │ │ └── Settings.svelte │ │ ├── style.css │ │ └── vite-env.d.ts │ ├── svelte.config.js │ ├── tailwind.config.js │ ├── tsconfig.json │ ├── tsconfig.node.json │ ├── vite.config.ts │ └── wailsjs │ ├── go │ │ ├── main │ │ │ ├── App.d.ts │ │ │ └── App.js │ │ └── models.ts │ └── runtime │ ├── package.json │ ├── runtime.d.ts │ └── runtime.js ├── go.mod ├── go.sum ├── internal │ ├── db │ │ └── db.go │ └── models │ ├── crypto.go │ ├── master_password.go │ └── password_entry.go ├── LICENSE ├── main.go ├── Makefile ├── README.md ├── scripts └── wails.json
MasterPassword 有一个私有字段(清除),不会在数据库中存储/检索(因此没有三叶草标签),即它只存在于内存中,不存储在磁盘上。此属性是未加密的主密码本身,将用作密码条目的加密密钥。该值由 MasterPassword 对象上的 setter 存储,或设置(由 callback)作为同名包的 struct Db 中的非导出(私有)字段(db.go)。对于密码输入,我们使用 2 个结构体,一个没有加密的密码,另一个结构体的密码已经加密,这是实际存储在数据库中的对象(类似于 DTO ,数据传输对象)。这两个结构体的加密/解密方法内部都使用 Crypto 对象,该对象具有带有加密密钥的属性(这是转换为 32 字节长切片的主密码):
/* main.go */ package main import ( "embed" "github.com/wailsapp/wails/v2" "github.com/wailsapp/wails/v2/pkg/options" "github.com/wailsapp/wails/v2/pkg/options/assetserver" "github.com/wailsapp/wails/v2/pkg/options/linux" ) //go:embed all:frontend/dist var assets embed.FS //go:embed build/appicon.png var icon []byte func main() { // Create an instance of the app structure app := NewApp() // Create application with options err := wails.Run(&options.App{ // Title: "Nu-i uita • minimalist password manager", Width: 450, Height: 300, DisableResize: true, AssetServer: &assetserver.Options{ Assets: assets, }, BackgroundColour: &options.RGBA{R: 27, G: 38, B: 54, A: 1}, OnStartup: app.startup, OnBeforeClose: app.beforeClose, Bind: []interface{}{ app, }, // Linux platform specific options Linux: &linux.Options{ Icon: icon, // WindowIsTranslucent: true, WebviewGpuPolicy: linux.WebviewGpuPolicyNever, // ProgramName: "wails", }, }) if err != nil { println("Error:", err.Error()) } }
主密码有 3 种在数据保存/恢复中发挥重要作用的方法:
/* app.go */ package main import ( "context" "github.com/emarifer/Nu-i-uita/internal/db" "github.com/emarifer/Nu-i-uita/internal/models" "github.com/wailsapp/wails/v2/pkg/runtime" ) // App struct type App struct { ctx context.Context db *db.Db selectedDirectory string selectedFile string } // NewApp creates a new App application struct func NewApp() *App { db := db.NewDb() return &App{db: db} } // startup is called when the app starts. The context is saved // so we can call the runtime methods func (a *App) startup(ctx context.Context) { var fileLocation string a.ctx = ctx runtime.EventsOn(a.ctx, "change_titles", func(optionalData ...interface{}) { if appTitle, ok := optionalData[0].(string); ok { runtime.WindowSetTitle(a.ctx, appTitle) } if selectedDirectory, ok := optionalData[1].(string); ok { a.selectedDirectory = selectedDirectory } if selectedFile, ok := optionalData[2].(string); ok { a.selectedFile = selectedFile } }) runtime.EventsOn(a.ctx, "quit", func(optionalData ...interface{}) { runtime.Quit(a.ctx) }) runtime.EventsOn(a.ctx, "export_data", func(optionalData ...interface{}) { d, _ := runtime.OpenDirectoryDialog(a.ctx, runtime. OpenDialogOptions{ Title: a.selectedDirectory, }) if d != "" { f, err := a.db.GenerateDump(d) if err != nil { runtime.EventsEmit(a.ctx, "saved_as", err.Error()) return } runtime.EventsEmit(a.ctx, "saved_as", f) } }) runtime.EventsOn(a.ctx, "import_data", func(optionalData ...interface{}) { fileLocation, _ = runtime.OpenFileDialog(a.ctx, runtime.OpenDialogOptions{ Title: a.selectedFile, }) // fmt.Println("SELECTED FILE:", fileLocation) if fileLocation != "" { runtime.EventsEmit(a.ctx, "enter_password") } }) runtime.EventsOn(a.ctx, "password", func(optionalData ...interface{}) { // fmt.Printf("MY PASS: %v", optionalData...) if pass, ok := optionalData[0].(string); ok { if len(fileLocation) != 0 { err := a.db.ImportDump(pass, fileLocation) if err != nil { runtime.EventsEmit(a.ctx, "imported_data", err.Error()) return } runtime.EventsEmit(a.ctx, "imported_data", "success") } } }) } // beforeClose is called when the application is about to quit, // either by clicking the window close button or calling runtime.Quit. // Returning true will cause the application to continue, false will continue shutdown as normal. func (a *App) beforeClose(ctx context.Context) (prevent bool) { defer a.db.Close() return false } ...
GetCrypto 允许您获取 Crypto 对象的当前实例,以便 db.go 包可以加密/解密密码条目。 SetClear 是我们之前提到的 setter,Check 是验证用户输入的主密码是否正确的函数;正如我们所看到的,除了密码之外,它还需要一个 callback 作为参数,根据情况,它会是前面提到的 setter (当我们从备份文件导入数据时) 或 db.go 包的 SetMasterPassword 方法,用于在用户登录时设置 Db 结构体的私有字段中的值。
我不打算详细解释 db.go 包的所有方法,因为它的大部分代码与 cloverDB 的使用方式相关,你可以在它的文档中查看,虽然我已经提到了一些将在这里使用的重要内容。
. ├── app.go ├── build │ ├── appicon.png │ ├── darwin │ │ ├── Info.dev.plist │ │ └── Info.plist │ ├── README.md │ └── windows │ ├── icon.ico │ ├── info.json │ ├── installer │ │ ├── project.nsi │ │ └── wails_tools.nsh │ └── wails.exe.manifest ├── frontend │ ├── index.html │ ├── package.json │ ├── package.json.md5 │ ├── package-lock.json │ ├── postcss.config.js │ ├── README.md │ ├── src │ │ ├── App.svelte │ │ ├── assets │ │ │ ├── fonts │ │ │ │ ├── nunito-v16-latin-regular.woff2 │ │ │ │ └── OFL.txt │ │ │ └── images │ │ │ └── logo-universal.png │ │ ├── lib │ │ │ ├── BackBtn.svelte │ │ │ ├── BottomActions.svelte │ │ │ ├── EditActions.svelte │ │ │ ├── EntriesList.svelte │ │ │ ├── Language.svelte │ │ │ ├── popups │ │ │ │ ├── alert-icons.ts │ │ │ │ └── popups.ts │ │ │ ├── ShowPasswordBtn.svelte │ │ │ └── TopActions.svelte │ │ ├── locales │ │ │ ├── en.json │ │ │ └── es.json │ │ ├── main.ts │ │ ├── pages │ │ │ ├── About.svelte │ │ │ ├── AddPassword.svelte │ │ │ ├── Details.svelte │ │ │ ├── EditPassword.svelte │ │ │ ├── Home.svelte │ │ │ ├── Login.svelte │ │ │ └── Settings.svelte │ │ ├── style.css │ │ └── vite-env.d.ts │ ├── svelte.config.js │ ├── tailwind.config.js │ ├── tsconfig.json │ ├── tsconfig.node.json │ ├── vite.config.ts │ └── wailsjs │ ├── go │ │ ├── main │ │ │ ├── App.d.ts │ │ │ └── App.js │ │ └── models.ts │ └── runtime │ ├── package.json │ ├── runtime.d.ts │ └── runtime.js ├── go.mod ├── go.sum ├── internal │ ├── db │ │ └── db.go │ └── models │ ├── crypto.go │ ├── master_password.go │ └── password_entry.go ├── LICENSE ├── main.go ├── Makefile ├── README.md ├── scripts └── wails.json
首先我们有一个结构体,它将存储一个指向 cloverDB 实例的指针。它还存储指向 MasterPassword 结构的“完整”实例的指针。这里的“完整”意味着它存储加密的主密码(意味着它存在于数据库中,因此是当前的主密码)和未加密的主密码,该密码将用于加密密码条目。接下来我们有 setupCollections、NewDb 和 Close,它们是在应用程序启动和关闭时设置数据库的函数和方法。 cloverDB在使用Open方法实例化时不会自动创建存储文件/目录,而是我们必须手动创建。最后,GetLanguageCode 和 SaveLanguageCode 是检索/保存用户选择的应用程序语言的方法。由于所选的语言代码是一个小字符串(“en”或“es”),为了简单起见,我们不使用结构来存储它:例如,从集合中检索语言代码(cloverDB 使用“文档”)和“集合”,类似于MongoDB),我们只需传递存储它的键(“代码”)并进行类型断言。
当用户第一次登录时,主密码会保存在数据库中:该值(未加密)设置在MasterPassword对象的明文字段中,该对象还存储了已经加密的密码密码,并保存在 Db 结构体中:
/* main.go */ package main import ( "embed" "github.com/wailsapp/wails/v2" "github.com/wailsapp/wails/v2/pkg/options" "github.com/wailsapp/wails/v2/pkg/options/assetserver" "github.com/wailsapp/wails/v2/pkg/options/linux" ) //go:embed all:frontend/dist var assets embed.FS //go:embed build/appicon.png var icon []byte func main() { // Create an instance of the app structure app := NewApp() // Create application with options err := wails.Run(&options.App{ // Title: "Nu-i uita • minimalist password manager", Width: 450, Height: 300, DisableResize: true, AssetServer: &assetserver.Options{ Assets: assets, }, BackgroundColour: &options.RGBA{R: 27, G: 38, B: 54, A: 1}, OnStartup: app.startup, OnBeforeClose: app.beforeClose, Bind: []interface{}{ app, }, // Linux platform specific options Linux: &linux.Options{ Icon: icon, // WindowIsTranslucent: true, WebviewGpuPolicy: linux.WebviewGpuPolicyNever, // ProgramName: "wails", }, }) if err != nil { println("Error:", err.Error()) } }
启动应用程序时会执行两次主密码恢复:
在这两种情况下,都会调用 RecoverMasterPassword 方法,仅当存储了主密码时,该方法才会在 Db 结构体的 cachedMp 字段中设置实例:
/* app.go */ package main import ( "context" "github.com/emarifer/Nu-i-uita/internal/db" "github.com/emarifer/Nu-i-uita/internal/models" "github.com/wailsapp/wails/v2/pkg/runtime" ) // App struct type App struct { ctx context.Context db *db.Db selectedDirectory string selectedFile string } // NewApp creates a new App application struct func NewApp() *App { db := db.NewDb() return &App{db: db} } // startup is called when the app starts. The context is saved // so we can call the runtime methods func (a *App) startup(ctx context.Context) { var fileLocation string a.ctx = ctx runtime.EventsOn(a.ctx, "change_titles", func(optionalData ...interface{}) { if appTitle, ok := optionalData[0].(string); ok { runtime.WindowSetTitle(a.ctx, appTitle) } if selectedDirectory, ok := optionalData[1].(string); ok { a.selectedDirectory = selectedDirectory } if selectedFile, ok := optionalData[2].(string); ok { a.selectedFile = selectedFile } }) runtime.EventsOn(a.ctx, "quit", func(optionalData ...interface{}) { runtime.Quit(a.ctx) }) runtime.EventsOn(a.ctx, "export_data", func(optionalData ...interface{}) { d, _ := runtime.OpenDirectoryDialog(a.ctx, runtime. OpenDialogOptions{ Title: a.selectedDirectory, }) if d != "" { f, err := a.db.GenerateDump(d) if err != nil { runtime.EventsEmit(a.ctx, "saved_as", err.Error()) return } runtime.EventsEmit(a.ctx, "saved_as", f) } }) runtime.EventsOn(a.ctx, "import_data", func(optionalData ...interface{}) { fileLocation, _ = runtime.OpenFileDialog(a.ctx, runtime.OpenDialogOptions{ Title: a.selectedFile, }) // fmt.Println("SELECTED FILE:", fileLocation) if fileLocation != "" { runtime.EventsEmit(a.ctx, "enter_password") } }) runtime.EventsOn(a.ctx, "password", func(optionalData ...interface{}) { // fmt.Printf("MY PASS: %v", optionalData...) if pass, ok := optionalData[0].(string); ok { if len(fileLocation) != 0 { err := a.db.ImportDump(pass, fileLocation) if err != nil { runtime.EventsEmit(a.ctx, "imported_data", err.Error()) return } runtime.EventsEmit(a.ctx, "imported_data", "success") } } }) } // beforeClose is called when the application is about to quit, // either by clicking the window close button or calling runtime.Quit. // Returning true will cause the application to continue, false will continue shutdown as normal. func (a *App) beforeClose(ctx context.Context) (prevent bool) { defer a.db.Close() return false } ...
接下来,有 2 段小但重要的代码:
EventsOn( ctx context.Context, eventName string, callback func(optionalData ...interface{}), ) func()
除了任何todoapp典型的CRUD操作之外,我们还有其他功能或方法可以评论:
. ├── app.go ├── build │ ├── appicon.png │ ├── darwin │ │ ├── Info.dev.plist │ │ └── Info.plist │ ├── README.md │ └── windows │ ├── icon.ico │ ├── info.json │ ├── installer │ │ ├── project.nsi │ │ └── wails_tools.nsh │ └── wails.exe.manifest ├── frontend │ ├── index.html │ ├── package.json │ ├── package.json.md5 │ ├── package-lock.json │ ├── postcss.config.js │ ├── README.md │ ├── src │ │ ├── App.svelte │ │ ├── assets │ │ │ ├── fonts │ │ │ │ ├── nunito-v16-latin-regular.woff2 │ │ │ │ └── OFL.txt │ │ │ └── images │ │ │ └── logo-universal.png │ │ ├── lib │ │ │ ├── BackBtn.svelte │ │ │ ├── BottomActions.svelte │ │ │ ├── EditActions.svelte │ │ │ ├── EntriesList.svelte │ │ │ ├── Language.svelte │ │ │ ├── popups │ │ │ │ ├── alert-icons.ts │ │ │ │ └── popups.ts │ │ │ ├── ShowPasswordBtn.svelte │ │ │ └── TopActions.svelte │ │ ├── locales │ │ │ ├── en.json │ │ │ └── es.json │ │ ├── main.ts │ │ ├── pages │ │ │ ├── About.svelte │ │ │ ├── AddPassword.svelte │ │ │ ├── Details.svelte │ │ │ ├── EditPassword.svelte │ │ │ ├── Home.svelte │ │ │ ├── Login.svelte │ │ │ └── Settings.svelte │ │ ├── style.css │ │ └── vite-env.d.ts │ ├── svelte.config.js │ ├── tailwind.config.js │ ├── tsconfig.json │ ├── tsconfig.node.json │ ├── vite.config.ts │ └── wailsjs │ ├── go │ │ ├── main │ │ │ ├── App.d.ts │ │ │ └── App.js │ │ └── models.ts │ └── runtime │ ├── package.json │ ├── runtime.d.ts │ └── runtime.js ├── go.mod ├── go.sum ├── internal │ ├── db │ │ └── db.go │ └── models │ ├── crypto.go │ ├── master_password.go │ └── password_entry.go ├── LICENSE ├── main.go ├── Makefile ├── README.md ├── scripts └── wails.json
loadPasswordEntryDTO 是一个辅助函数,它从从 cloverDB 获取的单个文档创建 PasswordEntryDTO 对象。 loadManyPasswordEntryDTO 执行相同的操作,但是从 cloverDB 文档的切片中生成 PasswordEntryDTO 的切片。最后,loadManyPasswordEntry 的作用与 loadManyPasswordEntryDTO 相同,但也解密从 getCryptoInstance 生成的 Crypto 对象实例中从 cloverDB 获取的文档。方法。
最后,在与CRUD无关的方法中,我们有用于数据导出/导入的方法:
/* main.go */ package main import ( "embed" "github.com/wailsapp/wails/v2" "github.com/wailsapp/wails/v2/pkg/options" "github.com/wailsapp/wails/v2/pkg/options/assetserver" "github.com/wailsapp/wails/v2/pkg/options/linux" ) //go:embed all:frontend/dist var assets embed.FS //go:embed build/appicon.png var icon []byte func main() { // Create an instance of the app structure app := NewApp() // Create application with options err := wails.Run(&options.App{ // Title: "Nu-i uita • minimalist password manager", Width: 450, Height: 300, DisableResize: true, AssetServer: &assetserver.Options{ Assets: assets, }, BackgroundColour: &options.RGBA{R: 27, G: 38, B: 54, A: 1}, OnStartup: app.startup, OnBeforeClose: app.beforeClose, Bind: []interface{}{ app, }, // Linux platform specific options Linux: &linux.Options{ Icon: icon, // WindowIsTranslucent: true, WebviewGpuPolicy: linux.WebviewGpuPolicyNever, // ProgramName: "wails", }, }) if err != nil { println("Error:", err.Error()) } }
GenerateDump 使用 DbDump 结构,它将作为保存在备份文件中的对象。它以用户选择的目录路径、日期格式和 ad hoc 扩展名作为名称。然后我们创建一个 DbDump 实例,其中包含加密的主密码、DTO 切片(其相应的密码也已加密)以及用户在数据库中保存的语言代码。这个对象最终被 Golang gob 包以二进制编码到我们创建的文件中,并将文件名返回给 UI,通知用户创建成功。
另一方面,ImportDump 将 UI 要求用户输入的主密码(即执行导出时有效的密码)以及备份文件的路径作为参数。现在,它使用 DbDump 结构解密所选文件,然后从 DbDump 中存储的加密主密码获取 MasterPassword 实例。在下一步中,我们验证用户提供的密码是否正确,同时在 MasterPassword 实例中设置清除字段:
/* app.go */ package main import ( "context" "github.com/emarifer/Nu-i-uita/internal/db" "github.com/emarifer/Nu-i-uita/internal/models" "github.com/wailsapp/wails/v2/pkg/runtime" ) // App struct type App struct { ctx context.Context db *db.Db selectedDirectory string selectedFile string } // NewApp creates a new App application struct func NewApp() *App { db := db.NewDb() return &App{db: db} } // startup is called when the app starts. The context is saved // so we can call the runtime methods func (a *App) startup(ctx context.Context) { var fileLocation string a.ctx = ctx runtime.EventsOn(a.ctx, "change_titles", func(optionalData ...interface{}) { if appTitle, ok := optionalData[0].(string); ok { runtime.WindowSetTitle(a.ctx, appTitle) } if selectedDirectory, ok := optionalData[1].(string); ok { a.selectedDirectory = selectedDirectory } if selectedFile, ok := optionalData[2].(string); ok { a.selectedFile = selectedFile } }) runtime.EventsOn(a.ctx, "quit", func(optionalData ...interface{}) { runtime.Quit(a.ctx) }) runtime.EventsOn(a.ctx, "export_data", func(optionalData ...interface{}) { d, _ := runtime.OpenDirectoryDialog(a.ctx, runtime. OpenDialogOptions{ Title: a.selectedDirectory, }) if d != "" { f, err := a.db.GenerateDump(d) if err != nil { runtime.EventsEmit(a.ctx, "saved_as", err.Error()) return } runtime.EventsEmit(a.ctx, "saved_as", f) } }) runtime.EventsOn(a.ctx, "import_data", func(optionalData ...interface{}) { fileLocation, _ = runtime.OpenFileDialog(a.ctx, runtime.OpenDialogOptions{ Title: a.selectedFile, }) // fmt.Println("SELECTED FILE:", fileLocation) if fileLocation != "" { runtime.EventsEmit(a.ctx, "enter_password") } }) runtime.EventsOn(a.ctx, "password", func(optionalData ...interface{}) { // fmt.Printf("MY PASS: %v", optionalData...) if pass, ok := optionalData[0].(string); ok { if len(fileLocation) != 0 { err := a.db.ImportDump(pass, fileLocation) if err != nil { runtime.EventsEmit(a.ctx, "imported_data", err.Error()) return } runtime.EventsEmit(a.ctx, "imported_data", "success") } } }) } // beforeClose is called when the application is about to quit, // either by clicking the window close button or calling runtime.Quit. // Returning true will cause the application to continue, false will continue shutdown as normal. func (a *App) beforeClose(ctx context.Context) (prevent bool) { defer a.db.Close() return false } ...
最后,我们从上一步中创建的 MasterPasword 实例中获取 Crypto 对象的实例,并在以下循环中执行 2 件事:
我们剩下的最后一件事就是将语言代码保存在数据库中。
今天就到这里了,本教程已经很长了??.
在第二部分中,我们将详细介绍前端,正如我所说,它是用 Svelte 制成的。
如果你不耐烦,正如我已经告诉你的,你可以在这个存储库中找到所有代码。
第二部分见。编码愉快?!!
以上是极简密码管理器桌面应用程序:进军 Golang 的 Wails 框架(第 1 部分)的详细内容。更多信息请关注PHP中文网其他相关文章!