bun-app-window 0.7.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +336 -73
- package/README.md +251 -142
- package/README.zh-CN.md +242 -142
- package/README.zh-TW.md +245 -141
- package/dist/libshell.dll +0 -0
- package/package.json +9 -5
- package/src/shell/app.ts +255 -2
- package/src/shell/assets.d.ts +4 -4
- package/src/shell/embedded.ts +9 -10
- package/src/shell/index.ts +380 -318
- package/src/shell/ipc.ts +91 -33
- package/src/shell/native.ts +69 -64
- package/src/shell/pack.ts +247 -28
- package/src/shell/port.ts +37 -38
- package/src/shell/resolve.ts +121 -140
- package/src/shell/state.ts +28 -74
- package/src/shell/types.ts +206 -81
- package/src/ui/titlebar.ts +1 -2
package/src/shell/resolve.ts
CHANGED
|
@@ -14,15 +14,7 @@
|
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
import { createHash } from "node:crypto";
|
|
17
|
-
import {
|
|
18
|
-
existsSync,
|
|
19
|
-
mkdirSync,
|
|
20
|
-
readdirSync,
|
|
21
|
-
renameSync,
|
|
22
|
-
rmSync,
|
|
23
|
-
statSync,
|
|
24
|
-
writeFileSync,
|
|
25
|
-
} from "node:fs";
|
|
17
|
+
import { existsSync, mkdirSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
26
18
|
import { homedir } from "node:os";
|
|
27
19
|
import { basename, dirname, join, resolve } from "node:path";
|
|
28
20
|
|
|
@@ -48,176 +40,165 @@ const INSTALLED = /[\\/]node_modules[\\/]/i.test(PKG_ROOT);
|
|
|
48
40
|
|
|
49
41
|
/** 是否运行在 `bun build --compile` 产物内 */
|
|
50
42
|
export function isPackedRuntime(): boolean {
|
|
51
|
-
|
|
52
|
-
/^[A-Za-z]:[\\/]~BUN[\\/]/i.test(import.meta.dir) ||
|
|
53
|
-
import.meta.dir.startsWith("/$bunfs/")
|
|
54
|
-
);
|
|
43
|
+
return /^[A-Za-z]:[\\/]~BUN[\\/]/i.test(import.meta.dir) || import.meta.dir.startsWith("/$bunfs/");
|
|
55
44
|
}
|
|
56
45
|
|
|
57
46
|
function sha256Hex(buf: Uint8Array): string {
|
|
58
|
-
|
|
47
|
+
return createHash("sha256").update(buf).digest("hex");
|
|
59
48
|
}
|
|
60
49
|
|
|
61
50
|
function runtimeDir(appName: string): string {
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
51
|
+
const base =
|
|
52
|
+
process.env.LOCALAPPDATA && process.env.LOCALAPPDATA.length > 0
|
|
53
|
+
? process.env.LOCALAPPDATA
|
|
54
|
+
: join(homedir(), "AppData", "Local");
|
|
55
|
+
return join(base, appName, "runtime");
|
|
67
56
|
}
|
|
68
57
|
|
|
69
58
|
function fromPath(path: string, source: NativeLibInfo["source"]): NativeLibInfo {
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
59
|
+
return {
|
|
60
|
+
path,
|
|
61
|
+
source,
|
|
62
|
+
version: PKG_VERSION,
|
|
63
|
+
sha256: "",
|
|
64
|
+
bytes: statSync(path).size,
|
|
65
|
+
reused: false,
|
|
66
|
+
pruned: [],
|
|
67
|
+
};
|
|
79
68
|
}
|
|
80
69
|
|
|
81
70
|
/** 写入目标目录;同名同大小视为可复用,写入走临时文件 + rename 保证原子性 */
|
|
82
|
-
function materialize(
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
71
|
+
function materialize(dir: string, bytes: Uint8Array, sha256: string): { path: string; reused: boolean } {
|
|
72
|
+
mkdirSync(dir, { recursive: true });
|
|
73
|
+
|
|
74
|
+
const finalName = `libshell-${sha256.slice(0, 16)}.dll`;
|
|
75
|
+
const finalPath = join(dir, finalName);
|
|
76
|
+
|
|
77
|
+
if (existsSync(finalPath)) {
|
|
78
|
+
try {
|
|
79
|
+
if (statSync(finalPath).size === bytes.length) {
|
|
80
|
+
return { path: finalPath, reused: true };
|
|
81
|
+
}
|
|
82
|
+
} catch {
|
|
83
|
+
// 读不到就重写
|
|
84
|
+
}
|
|
85
|
+
}
|
|
88
86
|
|
|
89
|
-
|
|
90
|
-
|
|
87
|
+
const tmpPath = join(dir, `.tmp-${process.pid}-${Date.now().toString(36)}.dll`);
|
|
88
|
+
writeFileSync(tmpPath, bytes);
|
|
91
89
|
|
|
92
|
-
if (existsSync(finalPath)) {
|
|
93
90
|
try {
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
91
|
+
renameSync(tmpPath, finalPath);
|
|
92
|
+
} catch (err) {
|
|
93
|
+
// Windows 上目标已存在时 rename 会失败:另一个实例可能刚好抢先写入
|
|
94
|
+
rmSync(tmpPath, { force: true });
|
|
95
|
+
if (!existsSync(finalPath)) throw err;
|
|
99
96
|
}
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
const tmpPath = join(dir, `.tmp-${process.pid}-${Date.now().toString(36)}.dll`);
|
|
103
|
-
writeFileSync(tmpPath, bytes);
|
|
104
|
-
|
|
105
|
-
try {
|
|
106
|
-
renameSync(tmpPath, finalPath);
|
|
107
|
-
} catch (err) {
|
|
108
|
-
// Windows 上目标已存在时 rename 会失败:另一个实例可能刚好抢先写入
|
|
109
|
-
rmSync(tmpPath, { force: true });
|
|
110
|
-
if (!existsSync(finalPath)) throw err;
|
|
111
|
-
}
|
|
112
97
|
|
|
113
|
-
|
|
98
|
+
return { path: finalPath, reused: false };
|
|
114
99
|
}
|
|
115
100
|
|
|
116
101
|
/** 删掉与当前哈希不符的旧副本;正被其它实例占用的删除失败则忽略 */
|
|
117
102
|
function pruneOldVersions(dir: string, keepName: string): string[] {
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
try {
|
|
121
|
-
entries = readdirSync(dir);
|
|
122
|
-
} catch {
|
|
123
|
-
return pruned;
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
for (const name of entries) {
|
|
127
|
-
if (!DLL_PATTERN.test(name)) continue;
|
|
128
|
-
if (name === keepName) continue;
|
|
103
|
+
const pruned: string[] = [];
|
|
104
|
+
let entries: string[];
|
|
129
105
|
try {
|
|
130
|
-
|
|
131
|
-
pruned.push(name);
|
|
106
|
+
entries = readdirSync(dir);
|
|
132
107
|
} catch {
|
|
133
|
-
|
|
108
|
+
return pruned;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
for (const name of entries) {
|
|
112
|
+
if (!DLL_PATTERN.test(name)) continue;
|
|
113
|
+
if (name === keepName) continue;
|
|
114
|
+
try {
|
|
115
|
+
rmSync(join(dir, name), { force: true });
|
|
116
|
+
pruned.push(name);
|
|
117
|
+
} catch {
|
|
118
|
+
// 正被其它实例加载,跳过
|
|
119
|
+
}
|
|
134
120
|
}
|
|
135
|
-
|
|
136
|
-
return pruned;
|
|
121
|
+
return pruned;
|
|
137
122
|
}
|
|
138
123
|
|
|
139
124
|
/** 从 Bun.embeddedFiles 找内嵌 DLL;没有则返回 null */
|
|
140
125
|
async function readBunEmbedded(): Promise<Uint8Array | null> {
|
|
141
|
-
|
|
142
|
-
.
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
return buf;
|
|
126
|
+
const files = (Bun as unknown as { embeddedFiles?: ReadonlyArray<Blob & { name: string }> }).embeddedFiles;
|
|
127
|
+
if (!files || files.length === 0) return null;
|
|
128
|
+
|
|
129
|
+
const hit = files.find((f) => DLL_PATTERN.test(f.name));
|
|
130
|
+
if (!hit) return null;
|
|
131
|
+
|
|
132
|
+
const buf = new Uint8Array(await hit.arrayBuffer());
|
|
133
|
+
if (buf.length < 2 || buf[0] !== 0x4d || buf[1] !== 0x5a) {
|
|
134
|
+
throw new Error(`embedded native library is not a PE file (${hit.name})`);
|
|
135
|
+
}
|
|
136
|
+
return buf;
|
|
153
137
|
}
|
|
154
138
|
|
|
155
139
|
export type ResolveOptions = {
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
140
|
+
nativeLibPath?: string;
|
|
141
|
+
appName?: string;
|
|
142
|
+
embedded?: () => Promise<Uint8Array | null>;
|
|
159
143
|
};
|
|
160
144
|
|
|
161
145
|
export async function resolveNativeLib(opts: ResolveOptions = {}): Promise<NativeLibInfo> {
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
146
|
+
const appName = opts.appName ?? "BunAppWindow";
|
|
147
|
+
|
|
148
|
+
// 1. 显式指定
|
|
149
|
+
if (opts.nativeLibPath) {
|
|
150
|
+
if (!existsSync(opts.nativeLibPath)) {
|
|
151
|
+
throw new Error(`nativeLibPath does not exist: ${opts.nativeLibPath}`);
|
|
152
|
+
}
|
|
153
|
+
return fromPath(opts.nativeLibPath, "explicit");
|
|
168
154
|
}
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
155
|
+
|
|
156
|
+
if (isPackedRuntime()) {
|
|
157
|
+
// 2. 内嵌字节 → 释放到用户目录
|
|
158
|
+
const bytes = opts.embedded ? await opts.embedded() : await readBunEmbedded();
|
|
159
|
+
if (bytes) {
|
|
160
|
+
const sha256 = sha256Hex(bytes);
|
|
161
|
+
try {
|
|
162
|
+
const { path, reused } = materialize(runtimeDir(appName), bytes, sha256);
|
|
163
|
+
return {
|
|
164
|
+
path,
|
|
165
|
+
source: "embedded",
|
|
166
|
+
version: PKG_VERSION,
|
|
167
|
+
sha256,
|
|
168
|
+
bytes: bytes.length,
|
|
169
|
+
reused,
|
|
170
|
+
pruned: pruneOldVersions(dirname(path), basename(path)),
|
|
171
|
+
};
|
|
172
|
+
} catch {
|
|
173
|
+
// %LOCALAPPDATA% 不可写(受限环境):继续尝试同级文件
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// 3. 可执行文件同级
|
|
178
|
+
for (const candidate of [
|
|
179
|
+
join(dirname(process.execPath), "libshell.dll"),
|
|
180
|
+
join(dirname(process.execPath), "dist", "libshell.dll"),
|
|
181
|
+
]) {
|
|
182
|
+
if (existsSync(candidate)) return fromPath(candidate, "package");
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
throw new Error(
|
|
186
|
+
"cannot locate libshell.dll in a compiled executable. " +
|
|
187
|
+
"Either place libshell.dll next to the executable, or import " +
|
|
188
|
+
"`bun-app-window/embedded` so it gets bundled. " +
|
|
189
|
+
`Searched: ${dirname(process.execPath)}`,
|
|
190
|
+
);
|
|
191
191
|
}
|
|
192
192
|
|
|
193
|
-
//
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
]) {
|
|
198
|
-
if (existsSync(candidate)) return fromPath(candidate, "package");
|
|
193
|
+
// 4. 开发态 / 包安装态:包根下的 .build/ 再 dist/
|
|
194
|
+
const source: NativeLibInfo["source"] = INSTALLED ? "package" : "dev";
|
|
195
|
+
for (const candidate of [join(PKG_ROOT, ".build", "libshell.dll"), join(PKG_ROOT, "dist", "libshell.dll")]) {
|
|
196
|
+
if (existsSync(candidate)) return fromPath(candidate, source);
|
|
199
197
|
}
|
|
200
198
|
|
|
201
199
|
throw new Error(
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
`Searched: ${dirname(process.execPath)}`,
|
|
200
|
+
"libshell.dll not found. Build the native layer first: " +
|
|
201
|
+
"`bun run native/build.ts`. " +
|
|
202
|
+
`Searched under ${PKG_ROOT}`,
|
|
206
203
|
);
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
// 4. 开发态 / 包安装态:包根下的 .build/ 再 dist/
|
|
210
|
-
const source: NativeLibInfo["source"] = INSTALLED ? "package" : "dev";
|
|
211
|
-
for (const candidate of [
|
|
212
|
-
join(PKG_ROOT, ".build", "libshell.dll"),
|
|
213
|
-
join(PKG_ROOT, "dist", "libshell.dll"),
|
|
214
|
-
]) {
|
|
215
|
-
if (existsSync(candidate)) return fromPath(candidate, source);
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
throw new Error(
|
|
219
|
-
"libshell.dll not found. Build the native layer first: " +
|
|
220
|
-
"`bun run native/build.ts`. " +
|
|
221
|
-
`Searched under ${PKG_ROOT}`,
|
|
222
|
-
);
|
|
223
204
|
}
|
package/src/shell/state.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { dirname, resolve } from "node:path";
|
|
|
3
3
|
|
|
4
4
|
import { EVENT_STATE, attachEventPump, type EventPump, unrefTimer } from "./events";
|
|
5
5
|
import type { Shell } from "./index";
|
|
6
|
+
import { isMinimizedSentinel, parseBounds, type WindowRect } from "./types";
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* 窗口位置与尺寸的持久化。
|
|
@@ -32,16 +33,26 @@ import type { Shell } from "./index";
|
|
|
32
33
|
* 位置变化就永久丢了——用户「拖完立刻点 X」是最常见的关闭方式。
|
|
33
34
|
*/
|
|
34
35
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
36
|
+
/**
|
|
37
|
+
* 窗口矩形的持久化形态——与 `Shell.getRect()` 返回的 `WindowRect` 是同一个类型。
|
|
38
|
+
*
|
|
39
|
+
* 不再各自定义一份形状相同的结构:两份类型会让 `savePlacement(file, shell.getRect())`
|
|
40
|
+
* 这种直接传递,在字段漂移时**静默丢字段**(结构宽的那个能赋给窄的,反过来不行,
|
|
41
|
+
* 而 TS 只报错不提「这是同一份数据」)。`tests/rect.test.ts` 钉住两者可互换。
|
|
42
|
+
*
|
|
43
|
+
* 语义差别只在用途上:`WindowRect` 说的是「此刻读到什么」,本别名说的是
|
|
44
|
+
* 「这份读数要落盘」。字段集合必须一致,因为它们本来就是同一次原生读取。
|
|
45
|
+
*/
|
|
46
|
+
export type WindowPlacement = WindowRect;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* 从 `./types` 转出解析器,保持 `bun-app-window/state` 的既有导入面不破。
|
|
50
|
+
*
|
|
51
|
+
* 真正实现在 `types.ts`:主入口 `getRect()` 也要用它,而本模块依赖 `node:fs`,
|
|
52
|
+
* 主入口不能反过来依赖这个可选件。两个子路径拿到的是**同一个函数对象**
|
|
53
|
+
* (`tests/rect.test.ts` 用 `toBe` 钉住),不会出现两份判据各说各话。
|
|
54
|
+
*/
|
|
55
|
+
export { parseBounds };
|
|
45
56
|
|
|
46
57
|
const DEFAULTS = {
|
|
47
58
|
/** 收到变化事件后攒多久再写盘。拖动期间事件每帧一条,攒到停手写一次 */
|
|
@@ -50,59 +61,6 @@ const DEFAULTS = {
|
|
|
50
61
|
pumpMs: 16,
|
|
51
62
|
} as const;
|
|
52
63
|
|
|
53
|
-
/**
|
|
54
|
-
* Win32 的「窗口已最小化」哨兵坐标。
|
|
55
|
-
*
|
|
56
|
-
* 窗口最小化时 `GetWindowRect` 返回的不是真实位置,而是一对哨兵值——实测
|
|
57
|
-
* `-32000,-32000` 加一个图标尺寸(典型 `314x50`)。它们**看起来完全合法**:
|
|
58
|
-
* 坐标是整数,尺寸也大于 0,所以只判「非有限 / 尺寸 ≤ 0」的形状校验拦不住。
|
|
59
|
-
*
|
|
60
|
-
* 成因在原生 `format_bounds`:它只在 `IsZoomed` 为真时走
|
|
61
|
-
* `WINDOWPLACEMENT.rcNormalPosition`,最小化时 `IsZoomed` 返回 false,于是落进
|
|
62
|
-
* `GetWindowRect` 分支——**`IsIconic` 从未被判断**。于是这对哨兵值会一路写成
|
|
63
|
-
* `{"x":-32000,"y":-32000,"width":314,"height":50}`,下次启动恢复成一个畸形小窗
|
|
64
|
-
* (实测:666×471,页面被挤到换行并出现滚动条)。
|
|
65
|
-
*
|
|
66
|
-
* 阈值取 -30000 而不是精确 -32000:不同 DPI / 主题下这对值会带几个像素偏移。
|
|
67
|
-
* 真实坐标到不了这个量级——8K 屏横向也只有 7680,多屏横排也远不到 30000,
|
|
68
|
-
* 所以用阈值不会误伤合法的负坐标(主屏在右、副屏在左时 x 确实是负数)。
|
|
69
|
-
*/
|
|
70
|
-
const MINIMIZED_SENTINEL = -30000;
|
|
71
|
-
|
|
72
|
-
/** 是不是 Win32 的最小化哨兵坐标。任一维命中即判否——两维是同时写的 */
|
|
73
|
-
function isMinimizedSentinel(x: number, y: number): boolean {
|
|
74
|
-
return x <= MINIMIZED_SENTINEL || y <= MINIMIZED_SENTINEL;
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
/** 把原生返回的一行文本解析成 WindowPlacement;格式不对返回 null。 */
|
|
78
|
-
export function parseBounds(text: string): WindowPlacement | null {
|
|
79
|
-
const parts = text.trim().split(",");
|
|
80
|
-
if (parts.length !== 6) {
|
|
81
|
-
return null;
|
|
82
|
-
}
|
|
83
|
-
const nums = parts.map((p) => Number(p));
|
|
84
|
-
if (nums.some((n) => !Number.isFinite(n))) {
|
|
85
|
-
return null;
|
|
86
|
-
}
|
|
87
|
-
const [x, y, width, height, maximized, dpi] = nums as [
|
|
88
|
-
number,
|
|
89
|
-
number,
|
|
90
|
-
number,
|
|
91
|
-
number,
|
|
92
|
-
number,
|
|
93
|
-
number,
|
|
94
|
-
];
|
|
95
|
-
// 尺寸为 0 或负数说明窗口还没建好(原生在无窗口时返回 0 长度文本,这里再兜一层)
|
|
96
|
-
if (width <= 0 || height <= 0 || dpi <= 0) {
|
|
97
|
-
return null;
|
|
98
|
-
}
|
|
99
|
-
// 最小化时原生给的是哨兵坐标,不是真实位置——宁可返回 null 让调用方保留上一次的值
|
|
100
|
-
if (isMinimizedSentinel(x, y)) {
|
|
101
|
-
return null;
|
|
102
|
-
}
|
|
103
|
-
return { x, y, width, height, maximized: maximized !== 0, dpi };
|
|
104
|
-
}
|
|
105
|
-
|
|
106
64
|
/**
|
|
107
65
|
* 校验外部来源(磁盘 JSON)里的 placement。
|
|
108
66
|
*
|
|
@@ -217,15 +175,9 @@ export type PlacementStoreOptions = {
|
|
|
217
175
|
* 首次 `attach` 会立刻抓一次快照并写盘——程序若在用户操作前崩溃,也还有
|
|
218
176
|
* 初始位置可恢复。
|
|
219
177
|
*/
|
|
220
|
-
export function attachPlacementStore(
|
|
221
|
-
shell: Shell,
|
|
222
|
-
file: string,
|
|
223
|
-
opts: PlacementStoreOptions = {},
|
|
224
|
-
): PlacementStore {
|
|
178
|
+
export function attachPlacementStore(shell: Shell, file: string, opts: PlacementStoreOptions = {}): PlacementStore {
|
|
225
179
|
const debounceMs = Math.max(0, opts.debounceMs ?? DEFAULTS.debounceMs);
|
|
226
|
-
const pump =
|
|
227
|
-
opts.events ??
|
|
228
|
-
attachEventPump(shell, { intervalMs: Math.max(1, opts.pumpMs ?? DEFAULTS.pumpMs) });
|
|
180
|
+
const pump = opts.events ?? attachEventPump(shell, { intervalMs: Math.max(1, opts.pumpMs ?? DEFAULTS.pumpMs) });
|
|
229
181
|
const ownsPump = opts.events === undefined;
|
|
230
182
|
|
|
231
183
|
let last: WindowPlacement | null = null;
|
|
@@ -277,9 +229,11 @@ export function attachPlacementStore(
|
|
|
277
229
|
return;
|
|
278
230
|
}
|
|
279
231
|
// 串行化写入:并发 rename 同一目标会互相踩
|
|
280
|
-
pending = pending
|
|
281
|
-
|
|
282
|
-
|
|
232
|
+
pending = pending
|
|
233
|
+
.then(() => savePlacement(file, now))
|
|
234
|
+
.catch(() => {
|
|
235
|
+
// 写盘失败不该打断应用;下一次变化会重试
|
|
236
|
+
});
|
|
283
237
|
};
|
|
284
238
|
|
|
285
239
|
const scheduleWrite = (): void => {
|