# Chuyển từ hồ sơ Chrome trên máy

> Chuyển một tài khoản từ thư mục hồ sơ Chrome trên máy sang hồ sơ Browlix mà không sửa phần code làm việc với trang, từng tài khoản một và luôn quay lại được.

Nhiều app mở mỗi tài khoản bằng một thư mục hồ sơ Chrome trên máy — `launchPersistentContext("./chrome-profiles/acc-42")` trong Playwright, `--user-data-dir` ở chỗ khác. Chuyển sang Browlix chỉ đổi cách app *lấy* trình duyệt đó. Phần code làm việc với trang vẫn nhận một `BrowserContext` và không phải sửa.

Mọi đoạn code trong trang này là một vùng của [`examples/migrate-chrome/migrate.mts`](#chuong-trinh-day-du). Browlix chạy chương trình đó hai lượt trên máy lab Ubuntu và Windows với browlix.com: lượt đầu chuyển tài khoản, lượt sau thấy tài khoản đã chuyển xong.

## Cái gì đổi, cái gì giữ nguyên

| | Trước | Sau |
|---|---|---|
| Dữ liệu trình duyệt nằm ở đâu | Một thư mục trên một máy | Một hồ sơ Browlix, được niêm phong và đồng bộ sau mỗi phiên |
| App lấy trình duyệt thế nào | `launchPersistentContext(dir)` | `acquireSession` → `attach` → `connectOverCDP` |
| Proxy, ngôn ngữ, múi giờ, màn hình | Cờ dòng lệnh hoặc không có | Cài đặt của hồ sơ, trình duyệt áp dụng và báo lại |
| Code làm việc với trang (`whoIsSignedIn`, đăng bài, đọc…) | Giữ nguyên | Giữ nguyên |

**Browlix không nhập thư mục hồ sơ Chrome hay cookie của nó.** Tài khoản đăng nhập một lần trong hồ sơ Browlix mới — bằng code đăng nhập của chính app, hoặc một người đăng nhập qua màn xem trực tiếp trên web. Hãy tính trước bước này, nhất là với tài khoản cần mã hoặc xác nhận khi đăng nhập.

## Code của chính app

Đây là phần giữ nguyên. `example.com` đứng thay cho trang thật; app của anh đọc tài khoản đang đăng nhập từ chính trang — ID người dùng hoặc tài khoản hiện trên trang, không bao giờ chỉ dựa vào việc có cookie.

```ts
/** The app's own code. It does not change in the move: it only needs a BrowserContext. */
async function whoIsSignedIn(context: BrowserContext): Promise<string | null> {
  const page = context.pages()[0] ?? (await context.newPage());
  await page.goto(SITE, { waitUntil: "domcontentloaded" });
  return page.evaluate(() => localStorage.getItem("signed-in-account"));
}

async function signIn(context: BrowserContext, account: string): Promise<void> {
  const page = context.pages()[0] ?? (await context.newPage());
  await page.goto(SITE, { waitUntil: "domcontentloaded" });
  await page.evaluate((a) => localStorage.setItem("signed-in-account", a), account);
}
```

## Trước

```ts
/** Before: one local Chrome profile directory per account. */
async function openLocalChrome(account: string) {
  const context = await chromium.launchPersistentContext(join(LOCAL_PROFILES, account), { headless: true });
  return { context, close: () => context.close() };
}
```

## Sau

Mỗi tài khoản một lần: tạo hồ sơ và gắn nó với tài khoản. Đặt cho hồ sơ đúng proxy tài khoản đã dùng trước đây (`proxy_id` trong `profiles.create`) để trang vẫn thấy cùng một mạng.

```ts
/** Once per account: a Browlix profile, bound to (app, account). Give it the proxy the account used before. */
async function bindAccount(account: string): Promise<{ profileId: string; created: boolean }> {
  const found = (await bx.call("bindings.list", { query: { app_id: APP, external_account_id: account } })).data[0];
  if (found) return { profileId: found.profile_id, created: false };
  const profile = await bx.call("profiles.create", { body: { profile: { name: `${APP} · ${account}`, config: { os: OS } } } });
  const b = await bx.call("bindings.create", { body: { app_id: APP, external_account_id: account, profile_id: profile.data.profile_id } });
  return { profileId: b.data.profile_id, created: true };
}
```

Sau đó, thay vì mở một thư mục, app giữ trình duyệt của tài khoản và gắn Playwright vào. Chạy trên đúng máy có worker Browlix mở trình duyệt: [gắn CDP chỉ trong cùng máy](https://browlix.com/docs/where-code-runs).

```ts
/** After: the account's browser runs in Browlix; the app holds it while it works and attaches Playwright. */
async function openBrowlix(account: string, deviceId: string) {
  const hold = await bx.acquireSession({ appId: APP, externalAccountId: account, holderRef: WORKER, deviceId, wait: { timeoutMs: 600_000 } });
  const a = await bx.attach(hold.sessionId, { ttlSeconds: 60 });
  const browser = await chromium.connectOverCDP(a.endpoint, { headers: a.headers });
  const context = browser.contexts()[0] ?? fail("the browser has no default context");
  return {
    context,
    sessionId: hold.sessionId,
    close: async () => {
      await browser.close(); // disconnects Playwright only
      await hold.detach({ keepWarmSeconds: 0 });
      await bx.stopSession(hold.sessionId);
      await bx.waitForSession(hold.sessionId, { until: "closed", timeoutMs: 180_000 });
    },
  };
}
```

`close()` dừng trình duyệt; dữ liệu được niêm phong và lưu sau khi đóng. Nếu app sắp làm tiếp với tài khoản, hãy nhả bằng `keepWarmSeconds` để việc sau dùng lại trình duyệt đang mở — xem [App giữ trình duyệt](https://browlix.com/docs/app-sessions).

## Chuyển một tài khoản

1. **Kiểm điểm xuất phát.** Mở hồ sơ trên máy bằng code cũ và xác nhận ai đang đăng nhập.
2. **Gắn** tài khoản với một hồ sơ Browlix mới (kèm proxy).
3. **Mở trong Browlix.** Lúc này chưa ai đăng nhập: không có gì được chép sang.
4. **Đăng nhập một lần** trong hồ sơ Browlix.
5. **Kiểm đúng tài khoản** bằng hàm kiểm của chính app. Nếu không phải tài khoản mong đợi thì đừng chuyển: sửa lại việc đăng nhập hoặc bỏ hồ sơ vào thùng rác rồi thử lại — việc đang chạy của tài khoản vẫn dùng thư mục cũ.
6. **Đổi đường chạy của tài khoản** sang `openBrowlix` và để một việc sau mở lại: tài khoản phải vẫn đăng nhập.
7. **Giữ nguyên thư mục cũ** cho đến khi tài khoản chạy ổn trên Browlix đủ lâu để anh chắc chắn. Quay lại nghĩa là cho tài khoản dùng lại đường chạy cũ.

Chuyển vài tài khoản trước, theo dõi, rồi mới chuyển phần còn lại. Không bao giờ dùng thư mục cũ và hồ sơ Browlix cho cùng một tài khoản cùng lúc.

## Lượt chạy ở lab kiểm gì

| Bước | Điều xảy ra |
|---|---|
| `local-before` | Thư mục trên máy đang đăng nhập `acc-42` |
| `bind` | Hồ sơ mới gắn với (`migrate-sample`, `acc-42`); lượt hai dùng lại liên kết |
| `first-open` | Lượt đầu: chưa ai đăng nhập. Lượt hai: đã là `acc-42` |
| `sign-in-once` | Chỉ đăng nhập khi cần |
| `verify-identity` | Hàm kiểm của app trả `acc-42`, nếu không lượt chạy dừng |
| `reopen` | Phiên mới trên cùng hồ sơ vẫn đăng nhập |
| `rollback-ready` | Thư mục trên máy vẫn mở ra với `acc-42` |

## Chạy thử

```bash
npm install
npm exec -- playwright install chromium-headless-shell   # chỉ cho nửa "trước"
BROWLIX_API_KEY=bxk_… node migrate.mts
```

Khoá cần `device.read`, `profile.read`, `profile.create`, `profile.edit`, `session.read`, `session.start`, `session.stop` và `session.attach`. `BROWLIX_ACCOUNT`, `BROWLIX_APP_ID` và `LOCAL_PROFILES` đổi tài khoản, app và thư mục chứa hồ sơ trên máy.

## Chương trình đầy đủ

**`package.json`**

```json
{
  "name": "browlix-migrate-chrome-sample",
  "private": true,
  "type": "module",
  "description": "Move an account from a local Chrome profile directory to a Browlix profile without changing the app's page code (DV15).",
  "scripts": {
    "start": "node migrate.mts",
    "check": "tsc -p ."
  },
  "dependencies": {
    "@browlix/sdk": "https://browlix.com/api/public/sdk/v1/download/0.10.0",
    "playwright": "^1.56.0"
  },
  "devDependencies": {
    "@types/node": "^22.0.0",
    "typescript": "^5.6.0"
  }
}
```

**`migrate.mts`**

```ts
/**
 * Move one account from a local Chrome profile directory to a Browlix profile (DV15).
 *
 *   before: the app opens ./chrome-profiles/<account> with Playwright
 *   after:  the app holds the account's Browlix browser and attaches the same Playwright code
 *
 * One run: check the local profile → bind the account → open it in Browlix → sign in once (the
 * local data is not copied) → check it is the right account → close → reopen (still signed in) →
 * the local directory is untouched, so switching back stays possible.
 *
 * Run it on the machine whose Browlix worker opens the browser (CDP attach is same-machine):
 *   BROWLIX_API_KEY=bxk_… node migrate.mts
 * Optional: BROWLIX_DEVICE_ID, BROWLIX_APP_ID (default migrate-sample), BROWLIX_ACCOUNT (default acc-42),
 * LOCAL_PROFILES (default ./chrome-profiles). The last line is `RESULT {json}`; exit code 0 = every step passed.
 */
import { hostname } from "node:os";
import { join } from "node:path";

import { Browlix, BrowlixError } from "@browlix/sdk";
import { chromium, type BrowserContext } from "playwright";

const apiKey = process.env["BROWLIX_API_KEY"];
if (!apiKey) {
  console.error("Set BROWLIX_API_KEY to a key with device.read, profile.read, profile.create, profile.edit, session.read, session.start, session.stop and session.attach.");
  process.exit(2);
}
const baseUrl = process.env["BROWLIX_API_BASE"];
const APP = process.env["BROWLIX_APP_ID"] ?? "migrate-sample";
const ACCOUNT = process.env["BROWLIX_ACCOUNT"] ?? "acc-42";
const LOCAL_PROFILES = process.env["LOCAL_PROFILES"] ?? "./chrome-profiles";
const OS = process.platform === "win32" ? "windows" : process.platform === "darwin" ? "macos" : "linux";
const WORKER = `migrate-${hostname().toLowerCase().replace(/[^a-z0-9-]+/g, "-")}`;
/** example.com stands in for the real site; your app reads the account from the site itself. */
const SITE = "https://example.com/";

const bx = new Browlix({ apiKey, ...(baseUrl ? { baseUrl } : {}) });
const result: Record<string, unknown> = { app_id: APP, external_account_id: ACCOUNT, os: OS };
const steps: { step: string; ok: boolean; ms: number; error?: string }[] = [];
function fail(msg: string): never {
  throw new Error(msg);
}

async function step<T>(name: string, fn: () => Promise<T>): Promise<T> {
  const t0 = Date.now();
  try {
    const v = await fn();
    steps.push({ step: name, ok: true, ms: Date.now() - t0 });
    console.log(`ok   ${name} (${Date.now() - t0} ms)`);
    return v;
  } catch (e) {
    const error = e instanceof BrowlixError ? `${e.code}: ${e.message}` : String((e as Error).message ?? e);
    steps.push({ step: name, ok: false, ms: Date.now() - t0, error });
    console.log(`FAIL ${name}: ${error}`);
    throw e;
  }
}

// #region work
/** The app's own code. It does not change in the move: it only needs a BrowserContext. */
async function whoIsSignedIn(context: BrowserContext): Promise<string | null> {
  const page = context.pages()[0] ?? (await context.newPage());
  await page.goto(SITE, { waitUntil: "domcontentloaded" });
  return page.evaluate(() => localStorage.getItem("signed-in-account"));
}

async function signIn(context: BrowserContext, account: string): Promise<void> {
  const page = context.pages()[0] ?? (await context.newPage());
  await page.goto(SITE, { waitUntil: "domcontentloaded" });
  await page.evaluate((a) => localStorage.setItem("signed-in-account", a), account);
}
// #endregion

// #region before
/** Before: one local Chrome profile directory per account. */
async function openLocalChrome(account: string) {
  const context = await chromium.launchPersistentContext(join(LOCAL_PROFILES, account), { headless: true });
  return { context, close: () => context.close() };
}
// #endregion

// #region bind
/** Once per account: a Browlix profile, bound to (app, account). Give it the proxy the account used before. */
async function bindAccount(account: string): Promise<{ profileId: string; created: boolean }> {
  const found = (await bx.call("bindings.list", { query: { app_id: APP, external_account_id: account } })).data[0];
  if (found) return { profileId: found.profile_id, created: false };
  const profile = await bx.call("profiles.create", { body: { profile: { name: `${APP} · ${account}`, config: { os: OS } } } });
  const b = await bx.call("bindings.create", { body: { app_id: APP, external_account_id: account, profile_id: profile.data.profile_id } });
  return { profileId: b.data.profile_id, created: true };
}
// #endregion

// #region after
/** After: the account's browser runs in Browlix; the app holds it while it works and attaches Playwright. */
async function openBrowlix(account: string, deviceId: string) {
  const hold = await bx.acquireSession({ appId: APP, externalAccountId: account, holderRef: WORKER, deviceId, wait: { timeoutMs: 600_000 } });
  const a = await bx.attach(hold.sessionId, { ttlSeconds: 60 });
  const browser = await chromium.connectOverCDP(a.endpoint, { headers: a.headers });
  const context = browser.contexts()[0] ?? fail("the browser has no default context");
  return {
    context,
    sessionId: hold.sessionId,
    close: async () => {
      await browser.close(); // disconnects Playwright only
      await hold.detach({ keepWarmSeconds: 0 });
      await bx.stopSession(hold.sessionId);
      await bx.waitForSession(hold.sessionId, { until: "closed", timeoutMs: 180_000 });
    },
  };
}
// #endregion

async function thisMachine(): Promise<string> {
  const fromEnv = process.env["BROWLIX_DEVICE_ID"];
  if (fromEnv) return fromEnv;
  const online = (await bx.call("devices.list")).data.filter((d) => d.os === OS && d.freshness === "current");
  if (online.length !== 1) fail(`found ${online.length} online ${OS} machines; set BROWLIX_DEVICE_ID to this machine's id`);
  return online[0]!.id;
}

let open: { close: () => Promise<void>; sessionId?: string } | null = null;
let exitCode = 1;
try {
  const deviceId = await step("machine", thisMachine);

  // the starting point: the account is signed in inside its local Chrome directory
  await step("local-before", async () => {
    const local = await openLocalChrome(ACCOUNT);
    open = local;
    if ((await whoIsSignedIn(local.context)) === null) await signIn(local.context, ACCOUNT);
    const who = await whoIsSignedIn(local.context);
    await local.close();
    open = null;
    if (who !== ACCOUNT) fail(`the local profile is signed in as ${String(who)}`);
  });

  const bound = await step("bind", () => bindAccount(ACCOUNT));
  Object.assign(result, { profile_id: bound.profileId, binding_created: bound.created });

  // first open in Browlix: nothing from the local directory came along
  const first = await step("first-open", async () => {
    const b = await openBrowlix(ACCOUNT, deviceId);
    open = b;
    result["session_id"] = b.sessionId;
    const who = await whoIsSignedIn(b.context);
    result["signed_in_on_first_open"] = who;
    return { b, who };
  });

  // sign in once inside the Browlix profile (or by hand in the web app's live view), then check the
  // account is the expected one before switching: a cookie is not proof of who is signed in
  await step("sign-in-once", async () => {
    if (first.who === null) await signIn(first.b.context, ACCOUNT);
  });
  await step("verify-identity", async () => {
    const who = await whoIsSignedIn(first.b.context);
    if (who !== ACCOUNT) fail(`signed in as ${String(who)}, expected ${ACCOUNT}: do not switch this account`);
    await first.b.close();
    open = null;
  });

  // the profile keeps the sign-in: a later job opens it already signed in
  await step("reopen", async () => {
    const b = await openBrowlix(ACCOUNT, deviceId);
    open = b;
    result["reopen_session_id"] = b.sessionId;
    const who = await whoIsSignedIn(b.context);
    await b.close();
    open = null;
    if (who !== ACCOUNT) fail(`after reopening, signed in as ${String(who)}`);
  });

  // switching back is still possible: the old directory was never touched
  await step("rollback-ready", async () => {
    const local = await openLocalChrome(ACCOUNT);
    open = local;
    const who = await whoIsSignedIn(local.context);
    await local.close();
    open = null;
    if (who !== ACCOUNT) fail(`the local profile changed: ${String(who)}`);
  });
  exitCode = 0;
} catch (e) {
  if (!steps.some((x) => !x.ok)) {
    result["error"] = String((e as Error).message ?? e);
    console.log(`FAIL ${result["error"]}`);
  }
  await (open as { close: () => Promise<void> } | null)?.close().catch(() => undefined);
} finally {
  result["steps"] = steps;
  result["ok"] = exitCode === 0;
  console.log(`RESULT ${JSON.stringify(result)}`);
  process.exit(exitCode);
}
```
