Typora 激活逻辑逆向 (Windows)

Typora Windows 版把许可证逻辑编进了 V8 字节码,没有 Mac 那样可直接改的原生闸口。本文记录如何对付 Electron Fuses + asar 打包 + 不可改的字节码,通过 hook 入口文件 launch.dist.js 绕过激活、消除启动弹窗,以及一路踩坑(独立弹窗窗口、注册表迷踪、诊断日志方法论)的全过程。

基本信息

  • 文件: Typora.exe (Windows PE x86-64, Electron 35.6.0)
  • 版本: 1.13.7 (releaseId: cf170905)
  • 工具: IDA Pro, Python3, strings
  • 目标: 分析并绕过 Typora Windows 版的激活逻辑
  • 前置: macOS 版 Writeup — 建议先读 Mac 版,理解整体激活流程

与 macOS 版的架构差异

这是理解本篇的关键前提:同一个应用,两个平台的保护方案完全不同。

macOS 版 Windows 版
核心二进制 Typora (Mach-O ARM64, Objective-C) atom.compiled.dist.jsc (V8 字节码)
许可证逻辑位置 原生编译的 ObjC 类 LicenseManager V8 bytecode cache,不可反编译
分析工具 IDA → 直接看伪代码 strings + 黑盒推断
Patch 方式 修改 12 字节 ARM64 指令 多层 hook + 字符串替换
保护层数 1 层 (代码签名) 3 层 (Electron Fuses + asar + 字节码)
许可证存储 {AppSupport}/.{fingerprint} (AES-256) 注册表 HKCU\Software\Typora
设备指纹 IOPlatformUUID HKLM\SOFTWARE\Microsoft\Cryptography\MachineGuid

一句话总结:Mac 版是拿手术刀在 X 光下精准切两刀;Windows 版是隔着一堵墙,一个一个出口堵。

1. 认识 Windows 版 Electron 项目结构

1.1 目录结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
Typora/
├── Typora.exe ← Electron shell (201MB)
├── resources/
│ ├── app.asar ← 主进程代码 (打包的)
│ │ ├── launch.dist.js ← 入口: 加载 .jsc 字节码
│ │ ├── atom.compiled.dist.jsc← ★ 核心逻辑 (V8 字节码,380KB)
│ │ └── package.json
│ ├── appsrc/
│ │ └── window/
│ │ └── frame.js ← ★ 渲染进程前端代码 (1.6MB)
│ ├── page-dist/
│ │ ├── license.html ← 许可证面板页面
│ │ └── static/js/LicenseIndex.*.js
│ ├── node_modules/
│ │ ├── main.node ← 原生模块 (PE DLL, 1MB)
│ │ ├── native-reg/ ← 注册表操作模块
│ │ └── ...
│ └── ...
├── version ← "35.6.0" (Electron 版本)
└── ...

1.2 入口分析

package.json 指定入口为 launch.dist.js

1
2
3
4
5
{
"name": "Typora",
"version": "1.13.7",
"main": "launch.dist.js"
}

launch.dist.js 的核心逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 注册 .jsc 文件的加载器 — 把 V8 字节码当 JS 模块加载
Module._extensions[".jsc"] = function(module, filename) {
let bytecode = fs.readFileSync(filename);

// 修复 V8 flag hash,使字节码与当前 V8 版本兼容
setFlagHashHeader(bytecode);

// 从字节码头部读取"源码长度",构造等长的 dummy 源码
let sourceLen = buffer2Number(getSourceHashHeader(bytecode));
let dummySource = '"' + '​'.repeat(sourceLen - 2) + '"';

// 用 V8 的 cachedData 机制加载字节码
let script = new vm.Script(dummySource, { cachedData: bytecode });

if (script.cachedDataRejected)
throw Error("Invalid or incompatible cached data");

// 执行字节码
script.runInThisContext();
};

// 加载核心逻辑
require("./atom.compiled.dist.jsc");

关键认知: .jsc 不是加密的 JS,而是 V8 字节码缓存 (bytecode cache)。
V8 引擎会把 JS 编译成字节码来执行,.jsc 文件就是这个编译产物。
它不能被反编译回可读的 JS,但里面的 字符串常量 是明文存储的。

1.3 与文章方法的区别

看雪文章《向Typora学习electron安全攻防》描述的是 老版本 Typora,当时的保护方案是:

1
2
3
老版本: main.node (C++ 编译) → 运行时解密 JS → 传给 Electron 执行

frida hook napi_create_string_utf8 可以截获明文

当前 v1.13.7 的方案完全不同:

1
2
3
4
新版本: launch.dist.js → 加载 atom.compiled.dist.jsc (V8 字节码)

字节码直接执行,不经过明文 JS 阶段
frida hook napi_create_string_utf8 截不到完整源码

所以文章的 frida 方法 对新版本不适用

2. 分析 V8 字节码中的许可证逻辑

虽然 .jsc 不能反编译,但里面的字符串常量是明文的。用 strings 命令就能提取:

2.1 许可证格式验证

1
2
$ strings atom.compiled.dist.jsc | grep "L23456789"
L23456789ABCDEFGHJKMNPQRSTUVWXYZ

与 Mac 版完全一致:

  • 正则: ^([A-Z0-9]{6}-){3}[A-Z0-9]{6}$
  • 字符集: 31 个字符 (排除 0/1/I/O 等易混淆字符)
  • 校验和: genCheckSum 隔位取字符索引累加,mod 31

2.2 在线激活 API

1
2
3
$ strings atom.compiled.dist.jsc | grep "api/client"
api/client/activate
api/client/deactivate

激活请求发送到 https://store.typora.io/api/client/activate,与 Mac 版相同。

2.3 签名验证

1
2
3
4
$ strings atom.compiled.dist.jsc | grep -E "publicDecrypt|jsDecrypt|sha256"
publicDecrypt
jsDecrypt
sha256

使用 Node.js crypto.publicDecrypt + SHA-256 验证服务器响应签名。
我们没有私钥,无法伪造签名 → 必须走本地绕过。

2.4 许可证本地存储

1
2
3
4
5
$ strings atom.compiled.dist.jsc | grep -E "Software.Typora|native-reg|IDate|WindowsLicense"
native-reg
Software\Typora
IDate
[WindowsLicenseLocalStore]

Windows 版使用 注册表 存储许可证信息,路径为 HKCU\Software\Typora

  • IDate: 安装日期 (用于计算试用期)
  • 其他许可证数据: 通过 native-reg 模块读写

对比 Mac 版使用隐藏文件 {AppSupport}/.{fingerprint} + AES-256 加密。

2.5 试用期与 UI 控制

1
2
3
4
5
6
7
$ strings atom.compiled.dist.jsc | grep -E "cannotContinue|hasLicense|fillLicense|UNREGISTERED"
cannotContinueUse
getHasLicense
hasLicense
onUnfillLicense
File.option.hasLicense = false;
dom.innerText="UNREGISTERED x";

流程与 Mac 版对应:

1
2
Mac: cannotContonueUse → hasLicense 方法 → _hasLicense ivar → postNotification
Win: cannotContinueUse → getHasLicense → 内部状态 → executeJavaScript 注入

具体的注入代码(从字节码中提取的完整片段):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 未激活时注入到渲染进程的 JS:
if(window.File.option) {
File.option.hasLicense = false; // ← 设置未激活状态
File.megaMenu && File.megaMenu.forceReload();
if(!document.querySelector("...")) {
const pos = Math.round(Math.random() * document.body.children.length);
const dom = document.createElement("DIV");
dom.style = "position: fixed !important; bottom: 2px !important; ...";
dom.innerText = "UNREGISTERED x"; // ← 角标文字
dom.addEventListener("click", () => {
dom.remove();
// 点击角标打开许可证面板
reqnode("electron").ipcRenderer.invoke(
String.fromCharCode(108,105,99,101,110,115,101,46,115,104,111,119)
// ↑ 这是 "license.show" 的 charCode 混淆
);
});
document.body.insertBefore(dom, ...);
}
}

2.6 许可证面板

许可证面板不是原生 dialog,而是一个 <webview> 嵌入在主窗口中:

1
2
3
4
5
6
7
8
9
10
11
// frame.js 中的 showLicensePanel 函数
showLicensePanel = function(url) {
var panel = document.querySelector("#uni-license-panel-view");
if (panel) {
panel.executeJavaScript("window.reloadData();1;");
} else {
panel = document.createElement("webview");
panel.setAttribute("src", url); // → license.html?needLicense=true&...
document.querySelector("#uni-license-panel").appendChild(panel);
}
};

URL 参数包括 needLicensehasActivatedemaillicense 等,由主进程字节码动态构造。

3. 保护机制分析

Windows 版有 三层保护,必须逐一突破:

3.1 第一层: V8 字节码 (.jsc)

核心许可证逻辑被编译为 V8 字节码,无法反编译为可读 JS,也无法像 Mac 版那样直接修改指令。

突破方式: V8 bytecode cache 不校验内容完整性。它只校验:

  • Magic number (V8 版本标识)
  • Flag hash (编译标志,已由 setFlagHashHeader 修复)
  • Source hash (源码长度,不是内容 hash)

因此,修改 .jsc 中的 字符串常量 不会导致 cachedDataRejected,只要保持字节长度不变。

3.2 第二层: Electron Fuses

Electron Fuses 是编译时嵌入 Typora.exe 的开关位,用于控制安全特性。

用 Python 在 Typora.exe 中搜索 fuse sentinel dL7pKGdnNz796PbbjQWNKmHXBZaB9tsX

1
2
3
4
偏移 0x9D00420: dL7pKGdnNz796PbbjQWNKmHXBZaB9tsX
Fuse 数据: 01 08 30 30 31 30 30 31 30 31
↑ ↑ ↑ ↑ ↑ ↑ ↑ ↑
0 1 2 3 4 5 6 7
Fuse 名称 含义
0 RunAsNode 0 (关) 不允许以纯 Node.js 模式运行
1 EnableCookieEncryption 0 (关)
2 EnableNodeOptionsEnvironmentVariable 1 (开) 允许 NODE_OPTIONS 环境变量
3 EnableNodeCliInspectArguments 0 (关) 不允许 –inspect 调试
4 EnableEmbeddedAsarIntegrityValidation 0 (关) ★ asar 完整性校验已关闭
5 OnlyLoadAppFromAsar 1 (开) 只从 .asar 文件加载
6 LoadBrowserProcessSpecificV8Snapshot 0 (关)
7 GrantFileProtocolExtraPrivileges 1 (开)

关键发现:

  • Fuse 4 = 0: asar 完整性 不校验 → 可以安全地修改并重打包 asar
  • Fuse 5 = 1: 只接受 .asar 文件 → 不能用文件夹替代,必须重打包回 .asar 格式

3.3 第三层: asar 打包格式

asar 是 Electron 的私有归档格式,结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
┌─────────────────────────────────────┐
│ Size Pickle (8 bytes) │
│ uint32: payload_size = 4 │
│ uint32: header_pickle_size │
├─────────────────────────────────────┤
│ Header Pickle │
│ uint32: payload_size │
│ uint32: json_length │
│ JSON header (文件名、大小、偏移) │
│ padding to 4-byte alignment │
├─────────────────────────────────────┤
│ 文件数据 (按 header 中的 offset 拼接) │
└─────────────────────────────────────┘

Header JSON 示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
{
"files": {
"atom.compiled.dist.jsc": {
"size": 379912,
"offset": "0",
"integrity": { "algorithm": "SHA256", "hash": "afce..." }
},
"launch.dist.js": {
"size": 1383,
"offset": "379912"
}
}
}

由于 Fuse 4 关闭,integrity 字段中的 hash 不会被实际验证。

3.4 main.node 分析 (排除项)

用 IDA Pro 分析了 resources/node_modules/main.node (PE DLL, x86-64):

  • 导出函数: napi_register_module_v1 (Node.js 原生模块标准入口)
  • 4642 个函数,但 无任何许可证相关字符串
  • 它是一个文件系统工具模块,与许可证无关
  • 这与看雪文章描述的老版本 main.node 完全不同

4. 绕过方案

需要修改 三个文件 来完整绕过:

4.1 Patch 1: 字节码字符串替换 (atom.compiled.dist.jsc)

V8 字节码中嵌入了渲染进程注入的 JS 代码字符串。直接修改这些字符串(保持长度不变):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Patch 1a @ 0x1EEDA:
原始: "hasLicense = false; " (20 字节)
修改: "hasLicense = true; " (20 字节)
^^^^
效果: 主进程注入到渲染进程的 JS 中,hasLicense 始终为 true

Patch 1b @ 0x1F0ED:
原始: 'UNREGISTERED x' (14 字节)
修改: ' ' (14 字节,全空格)
效果: 即使角标 DOM 被创建,文字也是空的

Patch 1c @ 0x895E:
原始: 'UNREGISTERED' (12 字节)
修改: ' ACTIVATED ' (12 字节)
效果: 状态标签显示"ACTIVATED"而非"UNREGISTERED"

为什么能改: V8 bytecode cache 只校验 magic/version/flags/source-length,
不校验字节码内容的完整性。只要字节长度不变,V8 会正常接受修改后的字节码。

4.2 Patch 2: 入口文件 hook 注入 (launch.dist.js)

require("./atom.compiled.dist.jsc") 之前注入代码,monkey-patch Electron API。

关键认识(来自诊断日志):启动时的「激活弹窗」不是主窗口里的某个 div,而是
字节码 new BrowserWindow() 创建的一个独立窗口,其顶层文档直接就是
typora://app/typemark/page-dist/license.html?...&dayRemains=14&hasActivated=false&needLicense=true
因此改主窗口的 CSS / frame.js / #uni-license-panel 都够不着它,也因为它走
typora:// 自定义协议,session.webRequest 同样拦不到。正确做法是在 loadURL
层面拦截:独立弹窗直接隐藏,内嵌 webview 则改写 URL 参数为「已激活」

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
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
// === 注入到 launch.dist.js 的 hook 代码(精简版)===
const _e = require("electron");
const _startT = Date.now();

// 把独立激活弹窗彻底隐藏(不销毁,避免字节码残留引用导致 "Object has been destroyed")
function _hideWindow(win) {
try {
win.hide(); win.setOpacity(0); win.setPosition(-32000, -32000);
win.show = win.showInactive = win.focus = function () {};
win.on("ready-to-show", () => { try { win.hide(); } catch (e) {} });
} catch (e) {}
}
// 把 license.html 的 URL 参数改写成「已激活」
function _activateUrl(url) {
return url.replace(/hasActivated=[^&]*/i, "hasActivated=true")
.replace(/needLicense=[^&]*/i, "needLicense=false")
.replace(/dayRemains=[^&]*/i, "dayRemains=9999");
}

// ① 启动 15 秒内屏蔽强制退出,但保留用户主动关窗退出
const _origQuit = _e.app.quit.bind(_e.app);
_e.app.quit = function () { if (Date.now() - _startT > 15000) _origQuit(); };
const _origExit = process.exit;
process.exit = function (c) { if (Date.now() - _startT > 15000) _origExit(c); };
_e.app.on("window-all-closed", () => { _origQuit(); }); // 否则关窗后进程残留

// ② 拦截试用到期/激活弹窗(消息框)
const _licRe = /license|trial|expir|activat|继续|试用|激活|注册|过期/i;
const _origMsgSync = _e.dialog.showMessageBoxSync;
_e.dialog.showMessageBoxSync = function () {
const o = arguments.length === 1 ? arguments[0] : arguments[1];
if (o && _licRe.test(o.message || "")) return 0;
return _origMsgSync.apply(_e.dialog, arguments);
};
// showMessageBox(异步版)同理,返回 {response:0}

// ③ 全局 IPC:getHasLicense → true
const _origHandle = _e.ipcMain.handle.bind(_e.ipcMain);
_e.ipcMain.handle = function (ch, fn) {
if (ch === "getHasLicense") return _origHandle(ch, async () => true);
if (ch === "license.show" || ch === "license.show.debug")
return _origHandle(ch, async () => "skip");
return _origHandle(ch, fn);
};

// ④ 在 BrowserWindow.loadURL 上拦独立弹窗(先于 webContents 钩子,防原生方法绕过)
_e.app.on("browser-window-created", (_evt, win) => {
const _winLoad = win.loadURL.bind(win);
win.loadURL = function (url) {
if (typeof url === "string" && url.includes("license.html")) {
_hideWindow(win);
return _winLoad("about:blank");
}
return _winLoad.apply(win, arguments);
};
});

// ⑤ webContents 级拦截
_e.app.on("web-contents-created", (_, wc) => {
const _type = wc.getType ? wc.getType() : "?";
const _loadURL = wc.loadURL.bind(wc);
wc.loadURL = function (url) {
if (typeof url === "string" && url.includes("license.html")) {
const win = _e.BrowserWindow.fromWebContents(wc);
if (win && _type === "window") { // 独立弹窗 → 隐藏
_hideWindow(win);
return _loadURL("about:blank");
}
const args = [].slice.call(arguments); // 内嵌 webview → 改参数
args[0] = _activateUrl(url);
return _loadURL.apply(wc, args);
}
return _loadURL.apply(wc, arguments);
};
// 主进程注入渲染器的 hasLicense=false 改成 true
const _exec = wc.executeJavaScript.bind(wc);
wc.executeJavaScript = function (code) {
if (typeof code === "string")
code = code.replace(/hasLicense\s*=\s*false/g, "hasLicense = true");
return _exec(code);
};
wc.on("did-finish-load", () => {
_exec("try{if(window.File&&File.option){File.option.hasLicense=true}}catch(e){}").catch(() => {});
});
});

为什么在 bytecode 加载前注入有效:

1
2
3
4
5
6
7
时序:
1. launch.dist.js 执行 hook
→ app.quit / ipcMain.handle 被替换
→ 注册 browser-window-created / web-contents-created 监听器
2. require("./atom.compiled.dist.jsc") 加载字节码
→ 字节码 new BrowserWindow() 加载 license.html → 触发监听器 → loadURL 被拦
→ 字节码调用 app.quit() / dialog → 被拦截

Electron 的 appipcMain 都是 单例对象。我们先 patch 它们的方法,
后面字节码拿到的是同一个对象,调用的就是我们 patch 过的版本。

诊断方法论:这一版能定位到「独立弹窗」是因为先做了一个诊断版 hook —— 把
browser-window-created / web-contents-created 的每个 URL、reg query/delete
结果、所有 ipcMain.handle 注册的频道名都写进 ~/typora_debug.log,启动一次就
拿到全部真相,而不是反复盲改盲试。最终版已移除全部日志代码。

4.3 Patch 3: 渲染进程前端修改 (frame.js)

frame.js 位于 asar 外部 (resources/appsrc/window/frame.js),可以直接修改。

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
// 原始: showLicensePanel 创建 webview 加载 license.html
showLicensePanel = function(e) {
var n = document.querySelector("#uni-license-panel-view");
if (n) n.executeJavaScript("window.reloadData();1;");
else {
n = document.createElement("webview");
n.setAttribute("src", e); // → license.html?needLicense=true&...
// ...
}
};

// 修改: 替换整个函数体,显示「已激活」面板
showLicensePanel = function(e) {
var t = document.querySelector("#uni-license-panel");
if (!t) return;
var n = document.querySelector("#uni-license-panel-view");
if (!n) {
n = document.createElement("div");
n.id = "uni-license-panel-view";
n.style.cssText = "display:flex;align-items:center;justify-content:center;height:100%";
n.innerHTML = '<div style="text-align:center">'
+ '<svg ...><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>' // ✓ 图标
+ '<polyline points="22 4 12 14.01 9 11.01"/></svg>'
+ '<h2>Typora 已激活</h2>'
+ '<p style="color:gray">感谢您的支持。</p>'
+ '<button id="_lic_close">关闭</button></div>';
t.appendChild(n);
n.querySelector("#_lic_close").onclick = function() {
t.style.display = "";
t.innerHTML = "";
};
}
t.style.display = "block";
};

这样用户手动打开许可证面板时会看到与 Mac 版一样的”已激活”界面,
而不是一个空白或激活表单。关闭按钮会隐藏面板并清理 DOM。

5. 三处 Patch 的配合关系

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
┌───────────────────────────────────────────────────────────┐
│ 主进程 (V8 字节码) │
│ │
│ cannotContinueUse() ──→ app.quit() ← Patch 2 ① 拦截 │
│ │ │
│ getHasLicense ──→ IPC 返回状态 ← Patch 2 ③ 返回true│
│ │ │
│ 注入 JS ──→ executeJavaScript() ← Patch 2 ④ 替换 │
│ │ hasLicense=false→true + Patch 1a 字符串 │
│ │ │
│ showLicensePanel() 通知渲染进程 │
└───────────┬───────────────────────────────────────────────┘


┌───────────────────────────────────────────────────────────┐
│ 渲染进程 (frame.js) │
│ │
│ showLicensePanel(url) → 显示已激活面板 ← Patch 3 替换函数 │
│ │
│ File.option.hasLicense = true ← Patch 1a + 2 ④ │
│ │
│ UNREGISTERED 角标 → 空文字 / 不创建 ← Patch 1b + 2 ④ │
│ │
│ 状态标签: " ACTIVATED " ← Patch 1c │
└───────────────────────────────────────────────────────────┘
检查点 触发位置 绕过方式
试用期过期 → 强制退出 主进程 app.quit() Patch 2 ① 启动期间拦截
试用期弹窗 主进程 dialog Patch 2 ② 自动点掉
getHasLicense 返回 false IPC 通道 Patch 2 ③ 返回 true
hasLicense = false 注入 executeJavaScript Patch 1a 字符串 + Patch 2 ④
UNREGISTERED 角标 executeJavaScript Patch 1b 字符串 + Patch 2 ④
许可证面板弹出 渲染进程 showLicensePanel Patch 3 显示「已激活」

6. 部署方式

方法一: 一键脚本 (推荐)

1
python3 patch_win.py [Typora安装目录]

脚本自动完成(5 步): 备份 → 解包 asar → patch 字节码 → patch launch.dist.js → 重打包
→ patch frame.js → patch window.html。每个被改的 asar 外文件都会先生成 .orig 备份。

⚠️ 测试前务必彻底退出 Typora(含右下角托盘图标 / 任务管理器里的所有 Typora.exe)。
Typora 是单实例:若已有旧进程在后台,新启动只会让旧进程开窗,你的新 patch 不会生效。

方法二: 手动操作

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
# 1. 安装 asar 工具
npm install -g @electron/asar

# 2. 备份
cd /path/to/Typora/resources
cp app.asar app.asar.orig
cp appsrc/window/frame.js appsrc/window/frame.js.orig

# 3. 解包
npx @electron/asar extract app.asar app_tmp

# 4. Patch 字节码 (用 Python 或十六进制编辑器)
python3 -c "
data = bytearray(open('app_tmp/atom.compiled.dist.jsc', 'rb').read())
# ... 应用字符串替换 ...
open('app_tmp/atom.compiled.dist.jsc', 'wb').write(data)
"

# 5. Patch launch.dist.js (在 require 前插入 hook 代码)

# 6. 重打包 (因为 Fuse 5 = OnlyLoadAppFromAsar 开启)
npx @electron/asar pack app_tmp app.asar

# 7. Patch frame.js (在 asar 外面,直接改)
# showLicensePanel=function(e){ → showLicensePanel=function(e){return;

恢复原始版本

1
2
3
cp resources/app.asar.orig                   resources/app.asar
cp resources/appsrc/window/frame.js.orig resources/appsrc/window/frame.js
cp resources/window.html.orig resources/window.html

7. 踩坑记录

坑 1: 文件夹替代 asar 不启动

最初尝试把 app.asar 改名为 .bak,用解压出的 app/ 文件夹替代。
结果: Typora 完全无反应,不报错,不启动。

原因: Electron Fuse 5 (OnlyLoadAppFromAsar) = 1,
强制只从 .asar 文件加载,忽略文件夹。

解法: 必须修改后重新打包回 .asar 格式。幸运的是 Fuse 4 (asar 完整性校验) = 0,
所以重打包的 asar 不需要匹配任何 hash。

坑 2: launch.dist.js 语法错误

原始文件是 单行压缩 的,末尾结构为:

1
...return s.apply(e.exports,d)},require("./atom.compiled.dist.jsc");

注意逗号 , — 这是逗号运算符,整行是一个表达式语句。

最初在 require(...) 前插入 try { ... } 语句块,
变成了 },try { ... }逗号后面不能跟语句,语法错误。

解法: 把逗号改成分号 };try { ... },正确终止前一条语句。

坑 3: hook 拦不住弹窗(走了好几轮弯路)

拦了 executeJavaScriptipcMain.handlewebContents.sendwebRequest.onBeforeRequest
又改了主窗口 window.html 的 CSS、frame.jsshowLicensePanel空白弹窗依旧出现

最终靠诊断日志才看清真相:弹窗是字节码 new BrowserWindow() 创建的一个独立窗口
顶层文档直接是 license.html。所以:

  • 改主窗口 CSS / frame.js / #uni-license-panel —— 全是另一个窗口里的事,够不着;
  • session.webRequest —— license.htmltypora:// 自定义协议,根本不触发;
  • 早期试过 webContents.close() 销毁它 —— 字节码仍持有该窗口引用,定时器回调
    触发 Object has been destroyed 崩溃。

正解:在 browser-window-created / web-contents-created 里拦 loadURL
判断到独立窗口加载 license.htmlhide() + 移到屏幕外 + 覆盖 show()
并改载 about:blank。窗口对象保持存活,字节码引用不失效,也就不崩。

坑 4: 试用天数 IDate 不在注册表

原以为试用倒计时存在 HKCU\Software\Typora\IDate,写了 reg delete 去清。
诊断日志显示 reg query 直接报「系统找不到指定的注册表项或值」——它压根不在那
dayRemains 是字节码自己算出来、拼进 license.html 的 URL 的。
所以根本不用碰注册表,直接在 loadURL 改写 URL 参数 dayRemains=9999 即可。

坑 5: 设置界面「未激活」(未完全解决)

设置面板 setting.htmlgetValue("hasLicense") 决定显示「已激活/未激活」,
该值作为 props 来自 window._options,而 _options = JSON.parse(IPC "setting.getExtraOption" 返回值)
本想拦这个 IPC 把 hasLicense 改成 true,但日志证实它既不走全局 ipcMain.handle
也不走 wc.ipc.handle / wc.mainFrame.ipc.handle
——三个入口都挂了拦截,一个都没命中,
注册路径未明。

由于这只是显示文字(真正决定能否使用的 hasLicense 已为 true,功能不受影响),
权衡代价后保留此现状,未继续深挖。

8. 总结

修改清单

文件 类型 修改内容
atom.compiled.dist.jsc asar 内 3 处字符串替换 (hasLicense / UNREGISTERED 角标)
launch.dist.js asar 内 注入约 5.8KB hook:退出拦截 + 弹窗隐藏 + URL 改写 + IPC
frame.js asar 外 showLicensePanel 置空(弹窗已在主进程拦掉,此处兜底)
window.html asar 外 注入 CSS 隐藏 #uni-license-panel(兜底)
合计 4 个文件,需重打包 asar

对比 Mac 版: 1 个文件,12 字节,重签名即可。

最终效果

状态
编辑器可用 (hasLicense=true)
启动空白激活弹窗 ✅ 已消除
关窗后台进程残留 ✅ 已修
试用到期强制退出 ✅ 已拦截
标题栏 UNREGISTERED 角标 ✅ 已清
设置界面文字「未激活」 ⚠️ 仅显示错误,不影响使用(见坑 5)

为什么 Windows 版难得多

  1. 不可见性: V8 字节码是黑盒,不能像 IDA 分析 ARM64 那样看伪代码
  2. 不可改性: 字节码的指令不能直接修改(格式复杂且版本绑定),只能改字符串
  3. 分散性: 逻辑分散在 字节码 / frame.js / license.html / 独立弹窗窗口 / 多套 IPC 之间
  4. 多层保护: 需要同时对付 Fuses + asar + 字节码,任何一层没搞定都不行
  5. 间接性: 只能通过 hook Electron API 间接影响字节码行为,而非直接改逻辑

方法论教训:Mac 版在原生层有 hasLicense() 这个唯一闸口,改一处全线联动;
Windows 版没有这种闸口,逻辑全在不可改的字节码里,只能逐个症状堵。盲改盲试
代价极高——真正高效的一步是先写诊断版 hook 打日志,一次启动看清所有窗口
URL / IPC / 注册表行为,再针对性下手。