cj-tauri开源项目介绍与imovie观影客户端项目实战

假期无聊,猫哥重拾「爱影家」影视App项目,用仓颉版Tauri(cj-tauri)打造跨平台桌面观影客户端。通过实战掌握cj-tauri框架:以仓颉为后端、Web技术为前端,实现窗口管理、IPC通信与权限控制。文章详解环境配置、工程创建、命令注册及网络坑点,强调"在玩中学"的学习理念,适合想快速上手仓颉开发的开发者。

假期在家实在没意思。不睡懒觉、不看电影、也不刷手机、不玩游戏,哪儿都不想去,闲到能搬个小板凳看蚂蚁上树,百无聊赖,感觉这假期过得真没劲。那干点啥?于是把之前做过的「爱影家」影视 App 又翻了出来,想找个能跨多端的技术栈再玩一把。

这次挑的是仓颉版的 Tauri,也就是 cj-tauri。用它来写一个windows版的观影客户端应用,演示下cj-tauri这个开源项目如何使用的。

cj-tauri项目开源地址 :gitcode.com/qq8864/cj-t...

写在前面:这个假期我干了件正事

说句心里话:学习一项新技能,最快也最踏实的办法就是动手实战,自己给自己找个项目练,这是我(猫哥)一贯的自学心得------在玩中学。所以我没先把文档一条条啃完,而是定了个目标:写一个能看片的桌面客户端,哪儿写不通,再回头查哪儿。

还有一句体己话:碎片化学习最可能的结局,是拖到「迟迟不敢上手」。找个自己感兴趣的题目把它做出来,乐趣和动力会自己找上门。


结论先放前面:cj-tauri 是一个用仓颉写桌面应用的框架。你写仓颉当后端,写 HTML/CSS/JS 当前端,它负责把两边缝起来------开窗口、把页面塞进系统 WebView、传消息、管权限。跟 Tauri 的思路一模一样,只是把 Rust 换成了仓颉,前端那套(Web 技术栈)一点没变。但比起Tauri项目,cj-tauri更简单有趣。因为仓颉语言语法很简单,比起rust来你更容易看懂和使用。者也算是给仓颉语言生态的一小点儿贡献吧。

这篇文章分两半。前半讲「它是什么、怎么装、怎么起一个工程」,内容不深,照着敲就行;后半拿仓库里的 examples/movie 当案例------那是一个能看片的桌面应用,我把它的目录、命令层、HTTP 层、前端逐块拆开讲。想直接跑起来,跳到第 5 节;想自己起一个,从头看。

先说清楚:这一路上我踩过的坑都会写进去(尤其是三条跟网络有关的),不粉饰。踩坑的地方我标了「实测」。


1. 它到底是个什么

一句话拆成三个零件,这个框架就是这三样:

零件 干什么 对标 Tauri 的
WebView 宿主 开窗口、载入页面、注入桥接脚本 tao / wry
IPC 桥 前端 invoke ↔ 后端命令,双向传 JSON tauri IPC
能力白名单 命令和事件要显式授权,不写进清单就用不了 capabilities

如果你写过 Tauri,看这张表就够了:TauriApp() ≈ tauri::Builder,CommandHandler ≈ #[tauri::command], capabilities/*.json 还是那个capabilities。没写过也没关系,后文会讲。

平台现状(我不写「理论上支持」这种话,只写跑过的):

  • Windows :WebView2,实机跑通,本机 Runtime 122.0.2365.106,截图和日志都是真跑出来的;
  • Linux:WebKitGTK,实机跑通;
  • 鸿蒙 ArkWeb:留了条件编译位,还没实现。

仓库在 AtomGit 上:atomgit.com/qq8864/cj-t...。当前版本 0.7.0。

那你为什么不直接用 Tauri?理由就两条:一是你想用仓颉(比如后面要上鸿蒙 PC,或者团队本来就写仓颉); 二是想看一个只有几千行的、能读懂的实现------Tauri 那套东西工程化程度很高,改起来不轻松。如果你既不需要仓颉也不需要读源码,Tauri 或者 Electron 都是更省事的选择,这话我得说明白。

2. 把环境弄对,比什么都重要

这一步有个特点:弄错了不会告诉你哪错了,要么报个莫名其妙的错,要么直接退出码 127、一个字都不打印。 所以我把三个前提按「出错时最像见鬼」的顺序排:

  1. 仓颉 SDK :验证过 1.2.0(含 stdx)。CANGJIE_HOME 指向 SDK 根目录,或者干脆 source SDK 自带的 envsetup 脚本。PATH 里需要 runtime/lib/<平台>、bin、 tools/bin、tools/lib 这几项------官方 envsetup 就是这个布局。
  2. stdx :框架和各示例的 cjpm.toml 都依赖它。不想改任何配置就把CANGJIE_STDX 设成 stdx 动态库目录(例如 D:\cangjie-stdx\...\dynamic\stdx)。
  3. 平台依赖 :
    • Windows:mingw-w64 的 gcc(编 C 桥用)+ WebView2 Runtime(Win11 自带)+ WebView2 SDK(只有编译桥时才需要 )。 WebView2 有个硬规矩:SDK 版本不得高于本机 Runtime ,本机 Runtime 是 122.0.2365.106,那就用 SDK 1.0.2365.46。装高了不会当场报错,是运行时崩。
    • Linux:libwebkit2gtk-4.1-dev、libgtk-3-dev,外加 gcc、pkg-config。

Git Bash 用户额外记一条(这条我踩过):往 PATH 里塞路径必须用 POSIX 形式 (/d/Program Files/...),写成 D:/Program Files/... 会被 MSYS 弄坏,结果是依赖仓颉运行时 DLL 的原生进程退出码 127、无任何输出 。好在 cli/cj-tauri.sh 内部自己做了这个转换,你用启动器就不必操心。

3. 起一个工程

别手写 cjpm.toml,那里面有一堆本机路径,手写纯属给自己找事。仓库自带 CLI(仓颉写的),首次运行它会自己 cjpm build 一次:

bat 复制代码
REM Windows
D:\path\to\cj-tauri\cli\cj-tauri.bat create myapp
cd myapp
D:\path\to\cj-tauri\cli\cj-tauri.bat dev
bash 复制代码
# Linux / macOS / Git Bash
/path/to/cj-tauri/cli/cj-tauri.sh create myapp
cd myapp
/path/to/cj-tauri/cli/cj-tauri.sh dev

dev 干的事是:构建 C 桥 → cjpm build → 起窗口,一直阻塞到你把窗口关掉。 不想 clone 仓库也行,有 npm 包:npx cj-tauri create myapp,CLI 本体优先用包里的预编译二进制,没有就本机构建一次缓存起来。

工程生成出来是这六个文件:

bash 复制代码
myapp/
├── cjpm.toml                  # 构建配置:框架依赖 + 各平台 link-option + stdx 路径
├── src/main.cj                # 仓颉入口:窗口配置 + 注册命令 + 启动
├── ui/index.html              # 前端页面(随你,Vue/React 也行,见下)
├── capabilities/default.json  # 能力白名单
├── README.md
└── .gitignore

三个模板可选:默认的 app(HTML/CSS/JS 全内联,零 Node,最省事)、create myapp --template vue、--template react(后两个带 ui/ 前端工程,cj-tauri dev 会接管 Vite dev server,改代码在窗口里热更)。

生成物是「本机绑定」的 :create 会把框架目录和你机器上 stdx 的绝对路径 写进 cjpm.toml。 所以换机器、换目录,要么重跑 create,要么手改那两个路径。入仓库的示例都改成了相对路径(path = "../.."), 不然别人克隆下来编不过。

CLI 就这几个子命令

命令 作用
create <项目名> 生成工程(my-app 会转成仓颉包名 myApp)
dev 构建桥 + 构建应用 + 起窗口(阻塞)
build 构建桥 + 构建应用,产物在 target/release/bin/main[.exe]
run 只运行已经构建好的产物
info 环境自检:框架 / 项目 / stdx / SDK / cjpm / 桥 分别解析到哪,末段列已装配的插件(排障第一步)
info --json 只吐插件清单 JSON,给工具或 CI 用
help 帮助

dev / build / run 会自动给子进程准备好动态库搜索路径(桥 + native/webview2/ + stdx + 仓颉运行时),不用像手动跑那样自己拼 PATH / LD_LIBRARY_PATH。这是我用下来最省事的一点:

同一个命令,Windows、Linux、Git Bash 下都能跑。

加一个自己的命令:三处联动,缺一处就用不了

这是 cj-tauri 最需要记的一条规矩,也是新人最常卡的地方。拿一个 greet 命令举例:

第一处,实现 CommandHandler (src/main.cj 或你自己拆的文件里):

ts 复制代码
public class GreetCommand <: CommandHandler {
    public init() {}
    public func handle(cmd: String, args: JsonObject, ipc: IpcContext): JsonValue {
        var name = "world"
        if (let Some(n) <- args.get("name")) {
            match (n.kind()) {
                case JsonKind.JsString => name = n.asString().getValue()
                case _ => ()
            }
        }
        return JsonString("你好,${name}")
    }
}

第二处,注册:

ts 复制代码
let app = TauriApp()
    .window(WindowConfig("我的应用", 1000, 700))
    .register("greet", GreetCommand())   // ← 这里

第三处,在能力清单里声明 (capabilities/default.json):

json 复制代码
{
  "identifier": "default",
  "windows": ["main"],
  "commands": ["greet"],
  "events": []
}

漏了第三处,前端会拿到 command not allowed;漏了第二处,是 command not registered。 这两条报错正好是两道体检,别把它们当同一个问题。

前端调用就三行(invoke / listen / emit,全挂在 window.__CJ_TAURI__ 上):

js 复制代码
await window.__CJ_TAURI__.invoke('greet', { name: '仓颉' });  // 调后端命令
window.__CJ_TAURI__.listen('tick', p => { /* 后端推来的事件 */ });
window.__CJ_TAURI__.emit('foo', { a: 1 });                    // 前端发事件

一个提醒:别在模块作用域直接读 window.__CJ_TAURI__。桥是宿主注入的,注入时机是「页面脚本执行前」,但发行态单文件里的内联模块脚本和它谁先谁后,不该由你的业务代码去赌。

稳妥做法是初始化时轮询等桥出现(官方 Vue / React 模板里的 waitForBridge 就是干这个的)。


4. 接下来看案例:examples/movie

讲 API 太干,我拿仓库里的观影应用当例子。选它不是因为好玩,是因为它把这条链路走到底了:

  • 数据全部 由仓颉侧取(榜单、搜索、详情、播放源、连封面图都是),前端里一处 fetch 都没有;
  • 七个命令、一份收窄到最小的能力清单;
  • 图片走宿主侧(因为防盗链),播放要引 hls.js,还有剧集/电影两种数据形状要分流;
  • 每个命令都在 stderr 留一行日志,实机验收不用开 devtools。

先说清用途 :examples/movie 只用于技术学习与研究。它不提供、不存储、不托管任何影视资源 , 片名、简介、封面、剧集与播放地址都取自第三方公开的测试接口(http://49.235.52.102:8000),也是个学习项目。 别拿它商用、批量抓取或二次传播;自己动手时请把接口换成自己的 mock 或测试数据 。 完整声明在 examples/movie/README.md 的「用途与免责声明」。

它长这样------首页榜单、搜索、详情三个视图:

另外还有第四个视图:播放页。剧集片源在播放器下面会排出一列集数按钮,多源的时候上面还有一行「播放源」胶囊,点一下换源、保留当前集数。这屏我没截图(画面里是第三方影视内容,不适合往仓库里放),它是否正常以日志为准,第 9 节会说看哪几行。

5. 跑起来

前置就是第 2 节那三件(SDK + stdx + WebView2),外加一条:C 桥要先构建一次 。 native/libcjtbridge.dll(Linux 是 .so)是本地产物、不入库,所以拉下代码得自己编一遍:

bat 复制代码
native\build_win.bat

然后直接双击 / 执行示例自带的启动脚本:

bat 复制代码
examples\movie\run.bat

run.bat 帮你做四件事,都是为了省掉手工拼环境的麻烦:

  1. cjpm build;
  2. 把 SDK 运行时、stdx、C 桥目录、native\webview2 全塞进 PATH;
  3. 切到 examples\movie 再启动 ------应用的 capabilities/ 和 ui/index.html 都是按相对路径读的, 工作目录不对就白搭(这条坑很常见,报错信息里也写了);
  4. 把应用 stderr 落到 %TEMP%\cj-movie-app.log,退出后自动摘出 [movie] 和 [frontend] 两类行。

顺带说一句为什么 .bat 里一个中文注释都没有:cmd.exe 是按 OEM 码页读 .bat 的, UTF-8 的中文注释会把后续整行 吞掉,甚至让脚本解析崩掉。这条是实测踩过的, 所以这个仓库有硬规矩------所有 .bat / .ps1 保持纯 ASCII,还写了脚本在检查它。

6. 代码是怎么分的

整个示例连前端一共七个文件,加起来不到 3000 行:

bash 复制代码
examples/movie/
  cjpm.toml                  # 应用包:依赖框架 + 各平台 link-option + stdx 路径
  capabilities/default.json  # 能力白名单:commands 就是命令名列表
  run.bat                    # Windows 启动脚本(纯 ASCII)
  src/main.cj                # 装配:窗口配置 → 注册七个命令 → 读页面 → run()
  src/api.cj                 # HTTP 传输层:TLS、超时、Referer、按块读流、图源白名单
  src/commands.cj            # 七个命令 + 参数读取/校验的助手
  ui/index.html              # 前端单页(HTML+CSS+JS 一体,无构建、无 Node)

三个仓颉文件的分工是刻意拆的:main.cj 只管装配,api.cj 只管网络,commands.cj 只管 「前端传上来的参数 → 该调哪个接口」。业务一多,全塞进 main.cj 会失控,这个尺寸的项目就该拆。

至于 ui/index.html 为什么是独立文件 、而不是像仓库里其他示例那样内联进 main.cj 的三引号字符串------ 因为一整页 UI 内联进去必然踩两个坑:① 三引号字符串会处理反斜杠转义,JS 里的 \n、正则会被仓颉先吃掉, 导致整段 <script> 语法错误,而 stderr 一个字都不提示 ,页面里一行都不执行(我们自己踩过, 排查了半天才发现是字符串的锅);② ${...} 插值和 JS 模板字面量同形,写模板串就等于往仓颉的插值里塞代码。 独立成文件后两个问题都没有,还能用 node --check 验语法。页面读进来的方式是:

cangjie 复制代码
let html = loadMovieUi()                       // 读 ui/index.html,返回 String
eprintln("[movie] 前端页面已载入:${MOVIE_UI_PATH}(${html.size} 字节)")
app.run(html)                                  // 交给宿主开窗口

最后那句 eprintln 不是随手加的。这个项目的实机取证以 stderr 为准 :仓颉 println 的 stdout 有缓冲, 进程被强杀时日志就丢了,而桥和自己打的 stderr 每行即时落盘。所以整个示例的日志一律走 eprintln。


7. 拆开看实现

7.1 入口:main.cj 只做三件事

配窗口、注册命令、把页面交给 run()。真的就这些:

ts 复制代码
main(): Int64 {
    eprintln("[movie] main start")
    eprintln("[movie] 后台接口:${movieApiBase()}(可用环境变量 CJ_MOVIE_API 覆盖)")

    var winCfg = WindowConfig("仓颉观影 · cj-tauri", 1280, 820)

    let app = TauriApp()
        .window(winCfg)
        .register("movie:list", MovieListCommand())
        .register("movie:search", MovieSearchCommand())
        .register("movie:detail", MovieDetailCommand())
        .register("movie:source", MovieSourceCommand())
        .register("movie:sourceitem", MovieSourceItemCommand())
        .register("movie:image", MovieImageCommand())
        .register("report", ReportCommand())

    let html = loadMovieUi()
    eprintln("[movie] 前端页面已载入:${MOVIE_UI_PATH}(${html.size} 字节)")
    app.run(html)
    return 0
}

窗口给 1280×820 是因为片单要横着排得开。能力清单没在这里出现------run() 会自动去扫capabilities/ 目录下的所有 json,这也是为什么清单文件改了不用动代码。

report 这条命令值得单独提一句:它什么业务都不干,只是把前端传来的字符串打到 stderr 上。 听着简陋,但它是这个项目的验收通道------页面跑在 WebView 里,不开 devtools 你看不见它的结论,有了这条命令,页面自检的结果就能和仓颉侧的日志落在同一个文件里。

7.2 命令层:页面传上来的一切都不可信

commands.cj 里的七个命令结构都一样:读参数 → 校验 → 调后台 → 原样返回 JSON。 真正费笔墨的是校验,因为页面可控的字符串有三个会被拼进 URL:

ts 复制代码
func isSafeSid(sid: String): Bool {        // 拼进路径 /api/v1/mvsource/<sid>
    if (sid.size == 0 || sid.size > 32) { return false }
    for (cp in sid) {                      // 迭代 String 拿到的是 UInt32 码点
        if (cp < 48 || cp > 57) { return false }   // 只放行数字
    }
    return true
}
func isSafeSourceNote(note: String): Bool  // 拼进查询串 ?note=<note>:只放行 a-z 0-9 _ -
func isSafeSourceAid(aid: String): Bool    // 只放行数字;单文件源(电影)的合法取值是空串

不校验会怎样?sid 拼的是路径,等于给后台开了一个「任意路径」入口(../../ 那种)。 note 放行集我刻意收窄到 [a-z0-9_-],这样拼查询串时连百分号转义都不用写------少一处转义,就少一处「转义写错、静默换了个源」的诡异 bug。

aid 允许空串是有原因的:单文件源(电影)在后台的 aid 就是 "",这是合法的真实取值,所以空串放行、拼接时把整个参数省掉。这种「看起来该拒其实是合法值」的边界,看接口文档看不出来,得实测一次。

还有一个容易忽略的校验------count 夹上限:

ts 复制代码
let n = if (count <= 0) { MOVIE_PAGE_SIZE } else if (count > 60) { 60 } else { count }

页面要是传个 count: 100000,后台就会去上游抓十万条,轻则拖慢、重则被限流。 命令层就是系统边界,这类夹取就该在这里做。

命令里出错直接用 throw CommandException("..."),它的消息会变成前端 catch 到的错误文本,所以别写「操作失败」这种废话,把原因写清楚(比如 未知榜单 kind="xx"(可选 hot/new/soon/top/week/us))。

命令和后台接口的对应关系,一张表:

命令 参数 后台接口
movie:list kind, start?, count?, city? POST /api/v1/<kind>movie(kind = hot/new/soon/top/week/us)
movie:search q, start?, count? POST /api/v1/searchmovie
movie:detail id POST /api/v1/detailmovie
movie:source sid GET /api/v1/mvsource/{sid}
movie:sourceitem sid, note, aid? GET /api/v1/mvsourceitem/{sid}?note=&aid=
movie:image url 无(仓颉侧把图片取回来)
report line 无(只打 stderr)

7.3 HTTP 层:三个必踩的地方

api.cj 是这个示例里信息量最大的文件,因为它替所有命令做了同一件事------发请求。这里有三条 「不知道就会栽」的事。

第一,TLS 必须显式配。 仓颉的 HTTP 客户端不带默认 TLS,发 https 请求直接抛HttpException: TLS must be configured when HTTPS requests are sent.:

ts 复制代码
func newMovieHttpClient(): Client {
    return ClientBuilder()
        .tlsConfig(TlsClientConfig())        // ← 少这一行,https 请求全灭
        .readTimeout(Duration.second * MOVIE_API_TIMEOUT_SECONDS)
        .writeTimeout(Duration.second * MOVIE_API_TIMEOUT_SECONDS)
        .autoRedirect(true)
        .build()
}

这条坑的阴险之处在于:后台接口是 http:// 的,所以一直好好的;等我去取封面(https://)时,54 次取图全军覆没 ,才回头看出来是 TLS 没配。TlsClientConfig() 默认走系统证书库校验,别图省事关掉校验------那等于把这条通道交给中间人。

第二,响应体不能 readToEnd 一把梭。 std.io.readToEnd(resp.body) 编译不过:它的约束是 T <: InputStream & Seekable,而 HttpResponse.body 的静态类型只有 InputStream,能 seek 的是它的实现类,编译期看不见。所以得自己按块读到 EOF:

ts 复制代码
func readBodyBytes(body: InputStream): Array<Byte> {
    let buf = ByteBuffer()
    let chunk = Array<Byte>(8192, repeat: 0)
    var n = body.read(chunk)
    while (n > 0) {
        if (n == chunk.size) { buf.write(chunk) } else { buf.write(chunk[0..n]) }
        n = body.read(chunk)
    }
    return buf.bytes()
}

第三,取响应头用 getFirst,没有 get。 写成 resp.headers.get("Content-Type") 会得到「enum pattern is not matched」这种完全不指向问题本身的报错------这种错误信息值得记下来,下次见到就知道大概是什么类型不对。

另外两个设计上的选择:

  • 每次调用现建一个 client,不做连接池共享。因为命令是在 worker 线程上执行的,跨线程共用一个 client 的连接池属于我没验证过的用法;宁可多花一次建连,也不埋一个偶发串包的雷。
  • 超时设 20 秒 。后台有一半接口是「现抓上游」的,上游不通它自己会卡到超时然后回 503;这边不设超时的话,前端那条 invoke 就一直转圈,用户看到的是「卡住」而不是「失败」------这两种体感差很多。

失败还要分两级处理,这个分类影响前端显示:

级别 形状 处理
传输级 连不上 / 非 200 / 响应不是 JSON 抛 CommandException,前端 catch 到错误
业务级 HTTP 200,但 data 是空的,message 写着 403 Forbidden 之类 原样返回给页面,由页面显示成一行提示

第二级如果也在命令层抛异常,页面就只能整页报错;而它其实是「这一个接口今天被限流了」,应该显示在列表位置上。所以 movieApiCall 把后台的 JSON 原封不动交给前端,不裁字段。

7.4 封面图:为什么非得绕仓颉侧

这是整个示例里我最想让人看见的一段,因为它的失败形状特别坑:HTTP 200,但 body 是 0 字节。

后台给的封面全是防盗链资源。实测对比(同一张图):

请求 结果
百度图片代理 + Referer: https://image.baidu.com/ 200,26628 字节,image/jpeg
同一地址 + 其它 Referer(或不带) 200,0 字节
豆瓣原图 + Referer: https://movie.douban.com/ 200,26628 字节
同一原图不带该 Referer 418

页面侧没法补救:浏览器只允许用 Referrer-Policy 减少 Referer、不允许替换,何况页面是宿主塞进来的(来源是 opaque),根本发不出图源要的那个 Referer。 所以封面只能由仓颉侧取------想带什么头就带什么头:

ts 复制代码
let resp = client.send(HttpRequestBuilder()
    .url(url).get()
    .header("Referer", referer)                  // 图源认这个
    .header("User-Agent", MOVIE_BROWSER_UA)      // 有些图源还要看 UA
    .header("Accept", "image/avif,image/webp,image/apng,image/*,*/*;q=0.8")
    .build())

取回来的字节转成 data:image/jpeg;base64,... 交给页面。这里有两个细节:

  • 图源要白名单 。movie:image 的参数是页面可控的任意地址,不校验的话这个命令就是「让后端替你请求任意 URL」的跳板(SSRF)。所以只放行后台确实会给的两家图源,其它一律拒掉。
  • 单张限 5MB 。不然一次 invoke 能把几十 MB 塞进 IPC。

前端那边配了一个取图队列:并发 4、同一地址去重、失败不留缓存,取不到就落在一张「生成式占位」上------标题首字 + 按标题散列的色相渐变。这比破图好看,也比转圈诚实。

实机一轮 38 次取图、30 次拿到字节,8 次是上游回的 200 空 body(含它自己的占位图),这个比例你大概有个数。

7.5 前端:四个视图,一处 fetch 都没有

ui/index.html 是一整页单文件,深色影院风:首页榜单(6 个 tab + 主角位大图 + 卡片网格 + 加载更多)、搜索结果、详情、播放。所有取数都走 invoke。开头第一件事是等桥:

js 复制代码
async function waitForBridge() {
  for (let i = 0; i < 200; i++) {          // 轮询等宿主把桥注入进来
    if (window.__CJ_TAURI__) return window.__CJ_TAURI__;
    await new Promise(r => setTimeout(r, 10));
  }
  throw new Error('桥没出现------检查页面是不是由宿主载入的');
}

然后有几个地方值得说:

剧集和电影是两种数据形状,得分开。 电视剧回的是 urls: [""](占位空串)+ tvurls: [...],电影才在 urls 里有内容。所以页面按「tvurls 有值就是剧集」来分流;空串一律当位置占位丢掉,不当可播地址(不然播放器会拿着空地址去加载)。

切播放源比切集要麻烦一层。 一部片子在后台往往挂着 20 个源(不同站点,甚至同名不同版本),而 mvsource/{sid} 这个接口只回主源那一份 urls / tvurls;别的源要按 note + aid 现取,走的是另一条命令 movie:sourceitem。所以播放页在集数按钮上方 还有一行「播放源」胶囊,点一下就是:取新源的剧集列表 → 保留当前集数重渲 → 从同一集起播(新源集数不够就落到它最后一集),标题也跟着换(切到那个「重制版」的源,片名就变了)。只有一个源的时候整行不显示,没得选就别占地方。

失败要显式判,不能只看有没有抛错。 页面的 invoke 被拒时确实会抛(比如 CommandException),但这个后台有个习惯:源不存在时它回 HTTP 200 + code:404 + message:"source not found" 。所以页面的判断是「拿到 JSON 了,但 code 不对」,而不是「有没有异常」。这一条是我实测出来的,文档里没写------所以切源失败会明确显示在播放器上,不做静默回退。

播放的事单独说一句。 后台给的多是 .m3u8,而 WebView2(Chromium)不原生支持 HLS,所以第一次播放 m3u8 时要从 CDN 取一个 hls.js(失败会回落到另一个 CDN);直链 mp4 走原生播放器,不联网取库。离线环境下首页、搜索、详情照常能用,播放会明确提示加载失败并把地址列出来------播放页底部那行「当前集地址 + 复制」就是给这种情况准备的,拷到 VLC 里试,比盯着一个黑屏有用。

自检面板。 页面加载完会自动跑一遍:system:version、首页榜单、详情,外加一次故意的越权调用 (movie:nope,这个命令不在白名单里)。结论显示在右下角,同时经 report 命令打到 stderr。所以日志里会出现一条 command not allowed------那是预期行为,是「白名单真的在拦」的证据,不是故障。

8. 三个真踩过的坑

按「会让你白折腾多久」排序,都是实测的。

8.1 stdx.net.http 不带默认 TLS

上面 7.3 提过,这里说它为什么会耽误事。后台接口是 http://,所以你的代码在很长一段时间里「看起来完全正常」;等到某个功能用了 https(这里是取封面),直接全灭。而报错只是一句TLS must be configured when HTTPS requests are sent.,跟「图片为什么不出来」隔着好几层。我的第一次表现是:看到 54 次取图全失败,先怀疑防盗链、再怀疑 CDN、最后才回去看客户端构造。

如果你只用 http 就用不到这条 ,这也正是它危险的地方------它只在你不注意的时候出现。记一句就够了:ClientBuilder().tlsConfig(TlsClientConfig())。

8.2 「网络问题」得分层,四层修法完全不同

这条我踩了两次,两次表象一模一样,原因却各不相同:

现象 真实原因 怎么确认
封面全不显示,<img> 报错 防盗链:HTTP 200 + 0 字节 同一地址换 Referer 对照,字节数就变了
播放页 manifestLoadError DNS:域名没有 A/AAAA 记录 curl 报 Could not resolve host
页面 opaque 来源 + 源站无 CORS 头 跨域 这是最常见的那一层,但未必是你这次遇到的那层

第二行那个例子特别典型:某个播放源的集地址域名 cdn.wlcdn99.com,用 DoH 查返回NOERROR 却没有任何 A/AAAA/CNAME 记录 ,连 DNS 都没过。可它在页面上报的是manifestLoadError------和跨域被拒一模一样的错误。如果照着「跨域」去调,只能是白折腾。

所以排查顺序就记这三个字:先解析域名 → 再看 TLS → 最后看跨域。别跳步。

顺带说,播放失败那句提示语我后来改成了中性描述(「原因可能是域名解析、跨域限制或源站失效」),因为原来的写法一口咬定是跨域------那是没验证过的断言,写进代码就等于骗下一个读它的人。

8.3 三引号字符串会吃反斜杠(内联 HTML+JS 必踩)

前面提过一半,这里补齐。仓颉的三引号字符串会处理反斜杠转义,所以你把一整页 HTML+JS 内联进去时:

swift 复制代码
写 "\n" → 变成真换行 → 把 JS 字符串或单行注释拆断 → 整段 <script> 语法错误

最要命的是没有任何提示 :stderr 干干净净,页面里一行 JS 都不执行,你会以为是桥没注入、是编码问题、是宿主限流,一路查下去。我们当时是靠拿 examples/hello 对照才定位到的。

两个规避办法,按推荐度排:

  1. 前端放独立文件 (本例就是这么干的),改完 node --check ui/index.html 之外的那段 JS 验一遍语法;
  2. 非要内联,就避开反斜杠:换行用 String.fromCharCode(10),正则改用 indexOf 判断,改完再拿 node --check 验一次。

8.4 顺手记几个工程上的小坑

  • 工作目录必须是项目根 。capabilities/、ui/ 都是相对路径读的,脚本里不 cd 到项目根就启动,页面直接读不到。
  • .bat 保持纯 ASCII。cmd.exe 按 OEM 码页读它,UTF-8 中文注释会吞掉后续行------这个仓库有脚本专门检查这条,别去挑战它。
  • C 桥的产物不入库 。所以拉到一个「改了 C 桥导出函数」的提交后,先重建桥 再构建应用;否则会在链接期 炸出一串 undefined reference to cj_bridge_*。那不是你的代码错了,是桥的 DLL 还是旧的(我第一次遇到时对着这串报错查了半天业务代码)。
  • 日志过滤要按整行前缀 。用 findstr /C:"menu " 这种带空格的中缀去筛,可能一条都匹配不上,于是「没有失败日志」被读成「没有失败」。按 [cj-bridge] 这样的整行前缀取证据。

9. 怎么验收:看日志,不用开 devtools

应用的 stderr 落在 %TEMP%\cj-movie-app.log(run.bat 帮你收的)。看这几类行就够判断了:

日志行 说明
[movie] main start 装配进到 main() 了
[cj-bridge] set window: ... hr=0x00000000 宿主窗口创建成功,hr 是全 0 才算数
[movie] 前端页面已载入:ui/index.html(N 字节) 页面读到了,N 要和磁盘上的字节数一致
[movie] /api/v1/hotmovie -> 200 ok bytes=... 页面点了,请求经仓颉侧发出去并拿到 200
[movie] image ... -> 200 ok bytes=... image/jpeg 封面经仓颉侧取回(bytes=0 就是被防盗链拒了)
[frontend] ... 页面侧的结论(含那条预期的越权对照组)
[frontend] movie:source(...) 剧集模式:N 集,从第一集起播 剧集分流命中,集数按钮已渲染
[frontend] 切集:第 N 集 -> ... 点了第 N 集(电影模式下不会有这行)
[frontend] 切源成功:<note>/<aid> -> N 集,起第 M 集(标题 ...) 换源成功并且保住了集数

一次真实运行的开头就长这样(2026-10-06):

csharp 复制代码
[movie] main start
[movie] 后台接口:http://49.235.52.102:8000(可用环境变量 CJ_MOVIE_API 覆盖)
[movie] 前端页面已载入:ui/index.html(63632 字节)
[cj-bridge] set window: title=仓颉观影 · cj-tauri 1280x820 hr=0x00000000

两条硬指标,比「窗口看起来能开」有用得多:

  1. 所有 hr= 都是 0x00000000;
  2. 除了那条故意的 command not allowed(movie:nope 对照组),没有别的拒绝或未注册报错。

播放页那两屏(在播画面、剧集与播放源切换)我没有截图,验收就看上面表里带 [frontend] 的几行---切集、切源请求 / 切源成功 出现,就说明那两屏是活的。这比截图可靠:截图只能证明「那一刻」,日志能证明「点了之后发生了什么」。

有个提醒:后台那套接口的上游状态是会变的。实测里 newmovie / weekmovie 会回HTTP 200 但 code=0 + message=403 Forbidden(上游限流),getmvmenus 直接超时。这跟框架没关系,但会让你误以为是自己代码的问题------先把「请求发出去了、后台回了什么」这两段日志看完,再动代码。

顺带说说这套看日志的土办法。它其实是猫哥的老习惯:不开 devtools、也不在页面里打 console,把所有结论都落到一个能 grep 的文件里。麻烦在前、省事在后------等你哪天要在没有界面、甚至没有屏幕的机器上查问题,就会知道「证据留在文件里」有多值。

10. 最后

仓库在 AtomGit:atomgit.com/qq8864/cj-t...,当前 0.7.0,代码是 MIT。 想自己起一个应用,最快的一条路还是那三行:

bash 复制代码
./cli/cj-tauri.sh create myapp --template app     # 或 vue / react
cd myapp
./cli/cj-tauri.sh dev

再想往下读的话,仓库里有几份文档是按不同目的写的:想按步骤学前端用 前端入门教程;当手册查 API 用使用文档; 想知道 IPC 协议本身长什么样、性能多少,看 IPC 通信机制; 要接现成能力(文件读写、原生对话框、执行子进程)看插件体系教程。 而 examples/movie 那份 README 是这个过程最细的版本,连我改过哪些参数、日志哪一行对哪一步都写了。

说点实话,免得有人抱错期望:

  • 这个项目在 0.x,接口还会变,别拿它交付严肃产品;
  • 鸿蒙 ArkWeb 后端还没实现(只留了条件编译位),macOS 也没做------如果你冲的是鸿蒙 PC,现在能拿到的是一套已经跑通 Windows / Linux 的实现,和一个不用改上层就能接新平台的结构;
  • 它最有价值的部分大概不是「能开窗口」,而是平台差异只被关在两个地方 :仓颉侧的 @When 条件编译和 C 桥的native/bridge_*.c,IPC、能力校验、命令分发这些全平台共用。

如果要加一个新平台,理论上就是补这两个位置里的实现。

从「玩」里入门

回头看,这一趟最值的不是记住了几个 API,而是「带着目标去玩」这件事本身的推进力: 因为想让它真能看片,我才去碰 HTTP、TLS、防盗链、HLS 这些平时不会主动翻开的东西;写不通就查,查完立刻能验证------这种反馈感,比按部就班读文档强太多。

所以如果你也想上手 cj-tauri(或者任何一个新框架),我的建议是别从「把文档读完」开始,先给自己挑个想做的题目:观影客户端、待办清单、自用的小工具都行。先让窗口开起来、让按钮有反应,后面的路自然会推着你往前走。别怕一开始写得丑,能跑的丑代码也比停在收藏夹里的教程有用。

这篇文章对应的观影客户端素材就在仓库的 examples/movie 里,纯学习用的。要是你觉得有点用,去 AtomGit 给这个项目点个星 (atomgit.com/qq8864/cj-t...)------对我是鼓励,对后来想上手的人来说也多一个能参考的例子。更欢迎照着它改出自己的版本。

最后再重复一句正事:examples/movie 只是拿第三方公开接口做的技术演示,不提供也不托管任何影视资源。你自己动手时,请把接口换成自己的 mock 或测试数据------既合规,也免得给别人的服务器添麻烦。


版权声明:本文为 CSDN 博主「特立独行的猫a」的原创文章,遵循 CC 4.0 BY-SA 版权协议, 转载请附上原文出处链接及本声明。

项目地址:atomgit.com/qq8864/cj-t...

相关推荐
fellow995 小时前
Electron + pnpm 在鸿蒙应用里跑起来
electron·harmonyos·openharmony·deepseek·deepseekharness
熊猫钓鱼>_>6 小时前
从闲置平板到家里的“控制大脑“:鸿蒙智慧中控面板完整实战
运维·人工智能·华为·自动化·电脑·ai编程·harmonyos
500846 小时前
React Native for OpenHarmony 实战:三方库 react-native-url-polyfill 的鸿蒙化适配指南
javascript·react native·react.js·性能优化·electron·harmonyos
熊猫钓鱼>_>9 小时前
从2D平铺到3D沉浸:我用HarmonyOS 7端侧AI做了一个全程数据不出设备的空间化私密相册
前端·人工智能·3d·华为·华为云·harmonyos·鸿蒙
m0_7381858210 小时前
Flutter 鸿蒙化实战:foundation_fluttify 适配 OpenHarmony,Fluttify 桥接层
flutter·华为·harmonyos·鸿蒙
m0_7381858210 小时前
Flutter 鸿蒙化实战:http_proxy 适配 OpenHarmony,HTTP 代理
flutter·http·华为·harmonyos·鸿蒙
m0_7381858210 小时前
Flutter 鸿蒙化实战:headset_connection_event 适配 OpenHarmony,耳机插拔监听
flutter·华为·harmonyos·鸿蒙
5008411 小时前
React Native for OpenHarmony 实战:三方库 react-native-volume-control 的鸿蒙化适配指南
javascript·react native·react.js·electron·harmonyos
李游Leo1 天前
HarmonyOS 7 DualCart 平行视界适配实录 04:EasyGo × 虚拟容器:商品比价双详情与分栏比例策略【鸿蒙心迹】
华为·harmonyos