Tauri 踩坑实录之有关 sidecar 的实践

前言

相信前端工程师应该很熟悉一个概念:微前端开发。通过将应用拆分为多个模块,各个模块独立开发测试,大大提升了开发灵活性,也降低了模块间的相互影响。

Tauri 的 sidecar 功能其实就是一种类似微前端的方案,通过 sidecar 功能,我们可以直接执行命令行程序并获取其输出,从而实现功能模块的拆分开发。

假设我们现在要通过 Tauri 开发一个视频剪辑软件,使用开源方案 FFmpeg,由于其本体是基于 c 实现的源代码,我们没办法直接使用,但是我们能找到编译好的可执行文件 FFmpeg Builds binaries,允许我们通过命令行的形式直接对视频进行剪辑操作。此时我们就可以使用 Tauri 实现图形化界面,sidecar 功能实现对 FFmpeg 的调用,完成视频剪辑软件的基本开发,比如这样 ffmpegGUI

当然这个过程并不是一帆风顺而十分美好的,让我们一起来试试。

简单上手

第一个命令行程序

创建命令行程序的方法有很多,相信大家也都有各自习惯的流程,我们简单地以 Visual Studio + c# 为例来进行我们的开发。我们首先创建一个基于 .NET Framework 的命令行程序,Visual Studio 会帮助我们自动生成工程文件。

Create Our First ConsoleApp

还记得程序员上手第一要义吗?Hello, World! 我们简单地添加一行打印然后编译。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace ConsoleApp
{
internal class Program
{
static void Main(string[] args)
{
+ Console.WriteLine("Hello, World!");
}
}
}

编译后生成的可执行文件即 .exe 文件我们可以在 ConsoleApp\bin\Debug 目录下找到,在命令行直接运行我们可以发现一切正常,程序可用,接下来就是让我们通过 sidecar 的方式在 Tauri 程序中调用我们的命令行程序。

Test ConsoleApp

Sidecar 的实现

根据官方文档 Embedding External Binaries,我们将 ConsoleApp.exe 复制到 src-tauri 目录下,然后修改我们的 tauri.conf.json 如下,注意不加 .exe 尾缀!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
{
...
"tauri": {
"allowlist": {
"all": false,
"shell": {
"all": false,
"open": true,
"sidecar": true,
"scope": [
{ "name": "ConsoleApp", "sidecar": true}
]
}
},
"bundle": {
...
"externalBin": [
"ConsoleApp"
],
...
},
}
}

踩坑:程序命名规范

此时我们直接执行 pnpm tauri dev 看看我们的 ConsoleApp 有没有被正确的进行打包。

然后发现,啪,报错了:

Rename ConsoleApp.exe

原来我们的命令行程序命名还得符合规范,必须得是 *-x86_64-pc-windows-msvc.exe,重新命名完后我们可以发现在目录 src-tauri\target\debug 下成功出现了我们的 ConsoleApp.exe,证明打包成功,我们可以尝试调用了。

Right bundle sidecar

Sidecar 的调用

我们首先要对 Greet.vue 组件进行简单的改造

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
<script setup lang="ts">
import { ref } from "vue";
import { invoke } from "@tauri-apps/api/tauri";
+ import { Command } from '@tauri-apps/api/shell'

const greetMsg = ref("");
const name = ref("");
+ const sidecarMsg = ref("");

async function greet() {
// Learn more about Tauri commands at https://tauri.app/v1/guides/features/command
greetMsg.value = await invoke("greet", { name: name.value });

+ const command = Command.sidecar('ConsoleApp')
+ const output = await command.execute();
+ sidecarMsg.value = output.stdout;
}
</script>

<template>
<div class="card">
<input id="greet-input" v-model="name" placeholder="Enter a name..." />
<button type="button" @click="greet()">Greet</button>
</div>

<p>{{ greetMsg }}</p>
+ <p>{{ sidecarMsg }}</p>
</template>

现在当我们按下 Greet 按钮时,如图所示,除了会执行原本的 greet 命令,还会通过 sidecar 功能执行我们的命令行程序,并获得其输出。

Greet and Sidecar

实战操作

添加相关库

对于实际可用的命令行程序来说,肯定还要能够添加对参数的识别和一定的功能。对于基于 c# 的命令行程序,推荐使用 Command Line Parser 作为参数识别工具,让我们来简单添加一下。

首先右键项目,然后点击 Manage NuGet Packages

Manage Nuget package

在 Browse 里搜索 CommandLineParser ,第一个应该就是,点击右边下载按钮即可

Install CommandLineParser

改造命令行程序

假设现在我们需要这样一个功能,获取文件创建时间和修改时间,用不同参数加以区分,我们对原来的程序进行如下改造

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
using System;
using System.IO;
using System.Collections.Generic;
using CommandLine;
using System.Text;

namespace ConsoleApp
{
internal class Program
{
public class Options
{
[Option('t', "test", Required = false, HelpText = "Trigger test actions.")]
public bool IsTest { get; set; }

[Option('c', "create_time", Required = false, HelpText = "Query file create time")]
public bool IsQueryCreateTime { get; set; }

[Option('m', "modify_time", Required = false, HelpText = "Query file modify time")]
public bool IsQueryModifyTime { get; set; }

[Value(1, HelpText = "Target file lists.")]
public IEnumerable<string> FileList { get; set; }

}

static void TestActions()
{
var currentTime = DateTime.Now.ToString("HH:mm:ss");

Console.WriteLine(currentTime + " Message from stdout");
Console.Error.WriteLine(currentTime + " Message from stderr");
}

static void Main(string[] args)
{
Console.OutputEncoding = Encoding.UTF8;

Parser.Default.ParseArguments<Options>(args)
.WithParsed<Options>(o =>
{
if (o.IsTest)
{
TestActions();
}

foreach(var item in o.FileList)
{
if (!File.Exists(item)) continue;

FileInfo _file = new FileInfo(item);

if (o.IsQueryCreateTime)
{
Console.WriteLine(_file.CreationTime.ToString());
}
else if (o.IsQueryModifyTime)
{
Console.WriteLine(_file.LastWriteTime.ToString());
}
}
});
}
}
}

编译后简单测试通过,准备更新我们的 sidecar 实现

Retest ConsoleApp

更新 sidecar 实现

需要注意的是,这个时候除了本来的 .exe 文件,我们还有 CommandLine.dll 同样需要打包整合

Remember to Bundle DLL

在复制后我们对 tauri.conf.json 进行修改

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
{
...
"tauri": {
"allowlist": {
...
"scope": [
{ "name": "ConsoleApp", "sidecar": true, "args": true}
]
}
},
"bundle": {
...
"resources": [
"CommandLine.dll"
],
...
},
}

对于 Greet.vue 我们只需要改一处地方

1
2
- const command = Command.sidecar('ConsoleApp')
+ const command = Command.sidecar('ConsoleApp', ["-t"])

踩坑:Not specifying resource map

此时我们尝试按下 Greet 按钮执行命令行程序会发现报错信息

Error with Sidecar

乍一看好像是格式问题,但是在我们将命令行程序的输出固定为 UTF8 格式时,仍然是相同的报错,说明问题与我们的格式无关。那会是什么问题呢?难道是程序没有正确打包?

可我们看 src-tauri\target\debug 目录下 .dll 文件和 .exe 文件都是存在的,直接执行也都没有问题,那是哪里出问题了?莫不是个 bug?

于是我们有了这个 issue: [bug] Always get Uncaught (in promise) invalid utf-8 sequence of 1 bytes from index 2 with sidecar

就结果而言,这确实是个bug:虽然看上去我们需要的资源都被打包进去了,但是他们并不能正确的找到彼此。解决方法当然也很简单:1. 等修复,相关的 pr feat: allow specifying a resource map, closes 现在还没被通过,pass。2. 指定 dll 文件的导入,这样相当于所有相关资源我们都需要指定明确的路径,有点麻烦。3. 把所有相关的 dll 都打包到 exe里,懒人懒办法。

对于我们来说,实现 3 是最简单的,甚至可以说是一步解决:因为我们只需要下载安装一个库 Costura.Fody

Install Costura.Fody

然后再次编译,得到如下输出目录,可见 dll 已经被打包到了我们的 exe 程序中

After Embeding DLL to EXE

让我们用最新的程序替换之前的程序,再次尝试执行会发现,一切都没有问题了

Good to Run Sidecar

注意事项

Costura.Fody 实际是将 ConsoleApp\bin\Debug or Release 目录下的所有 .dll 文件打包进 .exe,所以如果有第三方或者说自己写的 .dll,不要忘记添加 reference 并设置为 copy if newer

再进一步

当我们完成上述实践后,对如何基于 sidecar 开发应该已经有了一个初步的认识。踩坑当然不一定只踩了两个坑嘛,虽然这个坑可能很多人暂时是遇不到的:套娃。

事情是这样的,我已经为在开发的程序写好了最底层的 dll,为了方便的实现一部分功能,里面会通过 powershell 的实例执行一部分操作,类似这样

1
2
3
4
5
6
7
8
9
10
11
12
13
private void UnpinFolderFromQuickAccess(string path)
{
using (var runspace = RunspaceFactory.CreateRunspace())
{
runspace.Open();
var ps = PowerShell.Create();
var removeScript =
$"((New-Object -ComObject shell.application).Namespace(\"shell:::{{679f85cb-0220-4080-b29b-5540cc05aab6}}\").Items() | Where-Object {{ $_.Path -EQ \"{path}\" }}).InvokeVerb(\"unpinfromhome\")";

ps.AddScript(removeScript);
ps.Invoke();
}
}

截止到我们的 exe,程序执行一切正常,但当我们通过 sidecar 调用时,我们会发现程序会卡死在 ps.Invoke(),加了 try...catch... 后才能保证不影响程序的执行。

结语

Sidecar 无疑是个特别重要的功能,但目前来看也确实存在一些不完善之处,如果再遇到坑的话我也会及时更新。

希望我收获的这些经验能对你有所帮助,下个坑见!


Tauri 踩坑实录之有关 sidecar 的实践
http://example.com/2023/02/19/Tauri-踩坑实录之有关-sidecar-的实践/
作者
Steins Gu
发布于
2023年2月19日
许可协议