Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions bin/bm-view-preview.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,10 @@
import { getOptions } from "../lib/getOptions.js";
import { run } from "../lib/run.js";

const options = await getOptions();
run(options);
try {
const options = await getOptions();
await run(options);
} catch (error) {
console.error(`エラーが発生しました: ${error?.message ?? error}`);
process.exit(1);
}
75 changes: 75 additions & 0 deletions lib/chromiumProfile.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";

// SingletonLock symlinkの参照先 "hostname-pid" をパースする。
// hostname自体にハイフンを含むことがあるため、最後のハイフンで分割する。
export const parseSingletonLockTarget = (target) => {
const match = /^(.+)-(\d+)$/.exec(target);
if (!match) {
return null;
}
return { hostname: match[1], pid: Number(match[2]) };
};

export const isProcessAlive = (pid) => {
try {
process.kill(pid, 0);
return true;
} catch (error) {
// EPERMは「存在するが操作権限がない」なので生存扱い
return error.code === "EPERM";
}
};

// プロファイルを使用中の生きているChromiumプロセスのpidを返す。使用中でなければnull。
// 判定できないケース(lockなし・symlinkでない・パース不能・別ホストのlock・pid死亡)は
// すべてnullとし、扱いをChromium自身に委ねる(stale lockはChromiumが自力回復できる)。
export const getRunningChromiumPid = async (
profileDir,
{ hostname = os.hostname(), isAlive = isProcessAlive } = {},
) => {
let target;
try {
target = await fs.readlink(path.join(profileDir, "SingletonLock"));
} catch {
return null;
}
const parsed = parseSingletonLockTarget(target);
if (!parsed || parsed.hostname !== hostname) {
return null;
}
return isAlive(parsed.pid) ? parsed.pid : null;
};

// プロファイル破損などでChromiumが起動できなくなったとき、ログイン情報だけを
// 引き継いだ新しいプロファイルを作り直す。元のプロファイルは丸ごと退避して残す。
const carryOverEntries = [
"Local State", // Cookieの暗号鍵などを含む
"Default/Cookies",
"Default/Cookies-journal",
"Default/Login Data",
"Default/Login Data-journal",
"Default/Login Data For Account",
"Default/Login Data For Account-journal",
"Default/Local Storage",
"Default/WebStorage",
];

export const repairChromiumProfile = async (profileDir) => {
const backupDir = `${profileDir}.broken-${Date.now()}`;
await fs.rename(profileDir, backupDir);
await fs.mkdir(path.join(profileDir, "Default"), { recursive: true });

for (const entry of carryOverEntries) {
try {
await fs.cp(path.join(backupDir, entry), path.join(profileDir, entry), {
recursive: true,
});
} catch {
// 存在しないファイルはスキップ
}
}

return backupDir;
};
157 changes: 157 additions & 0 deletions lib/chromiumProfile.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import {
getRunningChromiumPid,
isProcessAlive,
parseSingletonLockTarget,
repairChromiumProfile,
} from "./chromiumProfile";

describe("parseSingletonLockTarget", () => {
test.each([
{ target: "myhost-12345", expected: { hostname: "myhost", pid: 12345 } },
// hostnameにハイフンを含む場合は最後のハイフンで分割する
{
target: "my-host.local-999",
expected: { hostname: "my-host.local", pid: 999 },
},
{ target: "nohyphen", expected: null },
{ target: "host-abc", expected: null },
{ target: "", expected: null },
])(
"parseSingletonLockTarget($target) -> $expected",
({ target, expected }) => {
expect(parseSingletonLockTarget(target)).toEqual(expected);
},
);
});

describe("isProcessAlive", () => {
test("自プロセスのpidは生存扱い", () => {
expect(isProcessAlive(process.pid)).toBe(true);
});

test("存在しないpidは死亡扱い", () => {
// pidの上限(2^22など)を大きく超える値はESRCHになる
expect(isProcessAlive(2 ** 30)).toBe(false);
});
});

describe("getRunningChromiumPid", () => {
let profileDir;

beforeEach(async () => {
profileDir = await fs.mkdtemp(
path.join(os.tmpdir(), "bm-view-preview-test-"),
);
});

afterEach(async () => {
await fs.rm(profileDir, { recursive: true, force: true });
});

const lockPath = () => path.join(profileDir, "SingletonLock");

test("SingletonLockがなければnull", async () => {
expect(await getRunningChromiumPid(profileDir)).toBe(null);
});

test("hostname一致かつpid生存ならpidを返す", async () => {
await fs.symlink(`${os.hostname()}-${process.pid}`, lockPath());
expect(await getRunningChromiumPid(profileDir)).toBe(process.pid);
});

test("pidが死んでいればnull(stale lockはChromiumに任せる)", async () => {
await fs.symlink(`${os.hostname()}-${process.pid}`, lockPath());
expect(
await getRunningChromiumPid(profileDir, { isAlive: () => false }),
).toBe(null);
});

test("hostnameが一致しなければnull", async () => {
await fs.symlink(`other-host-${process.pid}`, lockPath());
expect(
await getRunningChromiumPid(profileDir, { hostname: "this-host" }),
).toBe(null);
});

test("symlinkでなく通常ファイルならnull", async () => {
await fs.writeFile(lockPath(), `${os.hostname()}-${process.pid}`);
expect(await getRunningChromiumPid(profileDir)).toBe(null);
});

test("参照先がパース不能ならnull", async () => {
await fs.symlink("garbage", lockPath());
expect(await getRunningChromiumPid(profileDir)).toBe(null);
});
});

describe("repairChromiumProfile", () => {
let baseDir;
let profileDir;

beforeEach(async () => {
baseDir = await fs.mkdtemp(path.join(os.tmpdir(), "bm-view-preview-test-"));
profileDir = path.join(baseDir, "chromium_profile");
await fs.mkdir(path.join(profileDir, "Default", "Local Storage"), {
recursive: true,
});
await fs.writeFile(path.join(profileDir, "Local State"), "local state");
await fs.writeFile(path.join(profileDir, "Default", "Cookies"), "cookies");
await fs.writeFile(
path.join(profileDir, "Default", "Local Storage", "data"),
"storage",
);
// 引き継ぎ対象外
await fs.writeFile(path.join(profileDir, "Default", "History"), "history");
await fs.writeFile(path.join(profileDir, "Variations"), "variations");
});

afterEach(async () => {
await fs.rm(baseDir, { recursive: true, force: true });
});

test("元のプロファイルを退避し、ログイン関連のみ引き継ぐ", async () => {
const backupDir = await repairChromiumProfile(profileDir);

// 退避先に元の内容が丸ごと残っている
expect(backupDir).not.toBe(profileDir);
await expect(
fs.readFile(path.join(backupDir, "Default", "History"), "utf8"),
).resolves.toBe("history");

// 新しいプロファイルにはログイン関連だけがある
await expect(
fs.readFile(path.join(profileDir, "Local State"), "utf8"),
).resolves.toBe("local state");
await expect(
fs.readFile(path.join(profileDir, "Default", "Cookies"), "utf8"),
).resolves.toBe("cookies");
await expect(
fs.readFile(
path.join(profileDir, "Default", "Local Storage", "data"),
"utf8",
),
).resolves.toBe("storage");
await expect(
fs.access(path.join(profileDir, "Default", "History")),
).rejects.toThrow();
await expect(
fs.access(path.join(profileDir, "Variations")),
).rejects.toThrow();
});

test("引き継ぎ対象が存在しなくてもエラーにならない", async () => {
await fs.rm(path.join(profileDir, "Default", "Cookies"));
await fs.rm(path.join(profileDir, "Local State"));

const backupDir = await repairChromiumProfile(profileDir);

expect(backupDir).not.toBe(profileDir);
await expect(
fs.access(path.join(profileDir, "Default")),
).resolves.toBeUndefined();
});
});
38 changes: 33 additions & 5 deletions lib/run.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ import {
PreviewPage,
getEnvironment,
} from "./PreviewPage.js";
import {
getRunningChromiumPid,
repairChromiumProfile,
} from "./chromiumProfile.js";

const appEnvPaths = envPaths("bm-view-preview");

Expand All @@ -35,18 +39,42 @@ export const run = async ({
process.exit(1);
}

const browser = await chromium.launchPersistentContext(
path.join(appEnvPaths.cache, "chromium_profile"),
{
const profileDir = path.join(appEnvPaths.cache, "chromium_profile");

const launch = () =>
chromium.launchPersistentContext(profileDir, {
headless: false,
viewport: null, // ウィンドウのリサイズに合わせてviewportのサイズを変える
chromiumSandbox: true,
...(allowExtensions
? { ignoreDefaultArgs: ["--disable-extensions"] }
: {}),
...launchOptions,
},
);
});

let browser;
try {
browser = await launch();
} catch {
// 別のbm-view-preview / Chromiumが同じプロファイルで起動中の場合は修復せず案内する
const runningPid = await getRunningChromiumPid(profileDir);
if (runningPid !== null) {
console.error(
`bm-view-previewはすでに起動しています(プロセスID: ${runningPid})。\n` +
"既存のbm-view-previewのウィンドウを閉じてから、再度実行してください。",
);
process.exit(1);
}

// プロファイルの状態が原因でChromiumが起動時にクラッシュすることがあるため、
// ログイン情報だけを引き継いだプロファイルに作り直して再試行する
const backupDir = await repairChromiumProfile(profileDir);
console.error(
"ブラウザの起動に失敗したため、プロファイルを修復して再試行します(ログイン情報は引き継がれます)。\n" +
`元のプロファイルは ${backupDir} に退避しました。`,
);
browser = await launch();
}

// とりあえず最初のタブだけ監視対象にしている
const page = (await browser.pages())[0];
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@basemachina/bm-view-preview",
"version": "0.0.12",
"version": "0.0.13",
"description": "ローカル環境のJSXファイルをベースマキナのビューとしてプレビュー表示するツール",
"bin": {
"bm-view-preview": "./bin/bm-view-preview.js"
Expand Down
Loading