From 94a7df5bdc1b7b499e889620c7877883d49a4f82 Mon Sep 17 00:00:00 2001 From: Micah Lee Date: Fri, 31 Jul 2026 10:47:54 -0700 Subject: [PATCH] Implement isolated Bluesky local accounts (#673) --- .../platform-fixtures/accountFactory.ts | 6 +- .../controller.lifecycle.integration.test.ts | 208 ++++++++++++++++ .../ipc.deletion.integration.test.ts | 79 ++++++ .../bluesky_account_controller.ts | 183 ++++++++++++++ src/account_bluesky/index.ts | 2 + src/account_bluesky/ipc.ts | 17 ++ src/database.test.ts | 79 +++--- .../bluesky_local_account_migration.test.ts | 94 +++++++ src/database/account.ts | 86 ++++--- src/database/bluesky_account.ts | 235 +++++------------- src/database/migrations.ts | 36 ++- src/main.ts | 12 + src/preload.ts | 14 +- src/renderer/src/i18n/locales/en.json | 1 + src/renderer/src/test_util.ts | 6 +- .../view_models/XViewModel/view_model.test.ts | 2 +- src/renderer/src/views/AccountView.test.ts | 59 +++++ src/renderer/src/views/AccountView.vue | 27 +- src/renderer/src/views/TabsView.test.ts | 16 +- src/renderer/src/views/TabsView.vue | 3 +- src/shared_types/account.ts | 34 +-- 21 files changed, 902 insertions(+), 297 deletions(-) create mode 100644 src/account_bluesky/__tests__/integration/controller.lifecycle.integration.test.ts create mode 100644 src/account_bluesky/__tests__/integration/ipc.deletion.integration.test.ts create mode 100644 src/account_bluesky/bluesky_account_controller.ts create mode 100644 src/account_bluesky/index.ts create mode 100644 src/account_bluesky/ipc.ts create mode 100644 src/database/__tests__/bluesky_local_account_migration.test.ts diff --git a/src/__tests__/platform-fixtures/accountFactory.ts b/src/__tests__/platform-fixtures/accountFactory.ts index 63daa9f6..313867b7 100644 --- a/src/__tests__/platform-fixtures/accountFactory.ts +++ b/src/__tests__/platform-fixtures/accountFactory.ts @@ -43,9 +43,9 @@ export const createTestAccount = ( } break; case "Bluesky": - if (account.blueskyAccount) { - account.blueskyAccount.username = - options.username ?? account.blueskyAccount.username ?? "test"; + if (account.blueskyLocalAccount) { + account.blueskyLocalAccount.handle = + options.username ?? account.blueskyLocalAccount.handle ?? "test"; } break; } diff --git a/src/account_bluesky/__tests__/integration/controller.lifecycle.integration.test.ts b/src/account_bluesky/__tests__/integration/controller.lifecycle.integration.test.ts new file mode 100644 index 00000000..2b144adf --- /dev/null +++ b/src/account_bluesky/__tests__/integration/controller.lifecycle.integration.test.ts @@ -0,0 +1,208 @@ +import fs from "fs"; +import os from "os"; +import path from "path"; + +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +vi.mock("electron", () => ({ + app: { + getPath: vi.fn(() => ""), + getVersion: vi.fn(() => "0.0.1"), + }, + ipcMain: { handle: vi.fn() }, + session: { + fromPartition: vi.fn(() => ({ + closeAllConnections: vi.fn(), + clearStorageData: vi.fn(), + })), + }, +})); + +import { BlueskyLocalAccountController } from "../../bluesky_account_controller"; +import * as database from "../../../database"; + +describe("BlueskyLocalAccountController lifecycle", () => { + let temporaryRoot: string; + + beforeEach(() => { + temporaryRoot = fs.mkdtempSync( + path.join(os.tmpdir(), "cyd-bluesky-local-account-"), + ); + process.env.TEST_MODE = "1"; + process.env.TEST_SETTINGS_PATH = path.join(temporaryRoot, "settings"); + process.env.TEST_DATA_PATH = path.join(temporaryRoot, "data"); + database.getMainDatabase(); + database.runMainMigrations(); + }); + + afterEach(() => { + database.closeMainDatabase(); + fs.rmSync(temporaryRoot, { recursive: true, force: true }); + }); + + test("creates and reopens a UUID-keyed account after its handle changes", () => { + let account = database.createAccount(); + account = database.selectAccountType(account.id, "Bluesky"); + + const controller = new BlueskyLocalAccountController(account.id); + controller.open(); + const originalPaths = controller.paths; + controller.bindDid("did:plc:alice"); + expect(() => controller.bindDid("did:plc:someone-else")).toThrow( + "cannot be changed", + ); + controller.updateProfile({ + handle: "alice.test", + displayName: "Alice", + avatarUrl: "https://cdn.example/alice.jpg", + }); + controller.updateProfile({ handle: "renamed.test" }); + controller.close(); + + const reopened = new BlueskyLocalAccountController(account.id); + reopened.open(); + + expect(reopened.account.uuid).toBe(account.uuid); + expect(reopened.account.did).toBe("did:plc:alice"); + expect(reopened.account.handle).toBe("renamed.test"); + expect(reopened.paths).toEqual(originalPaths); + expect(reopened.paths.root).toBe( + path.join(process.env.TEST_DATA_PATH!, "Bluesky", account.uuid), + ); + expect(fs.existsSync(reopened.paths.database)).toBe(true); + expect(fs.existsSync(reopened.paths.media)).toBe(true); + expect(fs.existsSync(reopened.paths.staging)).toBe(true); + + if (process.platform !== "win32") { + expect(fs.statSync(reopened.paths.root).mode & 0o777).toBe(0o700); + expect(fs.statSync(reopened.paths.database).mode & 0o777).toBe(0o600); + } + + reopened.close(); + }); + + test("isolates content-addressed media, job state, and staging by UUID", () => { + const first = database.selectAccountType( + database.createAccount().id, + "Bluesky", + ); + const second = database.selectAccountType( + database.createAccount().id, + "Bluesky", + ); + const firstController = new BlueskyLocalAccountController(first.id); + const secondController = new BlueskyLocalAccountController(second.id); + firstController.open(); + secondController.open(); + + const firstMedia = firstController.storeMedia(Buffer.from("same media")); + const duplicateMedia = firstController.storeMedia( + Buffer.from("same media"), + ); + const secondMedia = secondController.storeMedia(Buffer.from("same media")); + firstController.saveJobState({ + id: "save-posts", + jobType: "save", + status: "running", + progress: { cursor: "one" }, + }); + firstController.close(); + firstController.open(); + + expect(duplicateMedia).toEqual(firstMedia); + expect(secondMedia.digest).toBe(firstMedia.digest); + expect(secondMedia.path).not.toBe(firstMedia.path); + expect(firstController.paths.staging).not.toBe( + secondController.paths.staging, + ); + expect(firstController.getJobState("save-posts")).toEqual({ + id: "save-posts", + jobType: "save", + status: "running", + progress: { cursor: "one" }, + }); + expect(secondController.getJobState("save-posts")).toBeNull(); + + if (process.platform !== "win32") { + expect(fs.statSync(firstController.paths.media).mode & 0o777).toBe(0o700); + expect(fs.statSync(firstController.paths.staging).mode & 0o777).toBe( + 0o700, + ); + expect(fs.statSync(firstMedia.path).mode & 0o777).toBe(0o600); + } + + firstController.close(); + secondController.close(); + }); + + test("rejects a duplicate DID without changing the second profile", () => { + const first = database.selectAccountType( + database.createAccount().id, + "Bluesky", + ); + const second = database.selectAccountType( + database.createAccount().id, + "Bluesky", + ); + const firstController = new BlueskyLocalAccountController(first.id); + const secondController = new BlueskyLocalAccountController(second.id); + firstController.open(); + secondController.open(); + firstController.bindDid("did:plc:shared"); + + expect(() => secondController.bindDid("did:plc:shared")).toThrow(); + expect(secondController.account.did).toBeNull(); + expect(database.getAccount(second.id)?.blueskyLocalAccount?.did).toBeNull(); + + firstController.close(); + secondController.close(); + }); + + test("requires UUID confirmation and deletes only the selected account", async () => { + const first = database.selectAccountType( + database.createAccount().id, + "Bluesky", + ); + const second = database.selectAccountType( + database.createAccount().id, + "Bluesky", + ); + const removedConnections: string[] = []; + const connectionStore = { + delete: async (uuid: string) => { + removedConnections.push(uuid); + }, + }; + const firstController = new BlueskyLocalAccountController( + first.id, + connectionStore, + ); + const secondController = new BlueskyLocalAccountController(second.id, { + delete: async () => undefined, + }); + firstController.open(); + secondController.open(); + firstController.storeMedia(Buffer.from("first")); + secondController.storeMedia(Buffer.from("second")); + fs.writeFileSync( + path.join(firstController.paths.staging, "import.part"), + "staged", + ); + + await expect(firstController.deleteConfirmed(second.uuid)).rejects.toThrow( + "confirmation", + ); + expect(removedConnections).toEqual([]); + expect(database.getAccount(first.id)).not.toBeNull(); + + await firstController.deleteConfirmed(first.uuid); + + expect(removedConnections).toEqual([first.uuid]); + expect(database.getAccount(first.id)).toBeNull(); + expect(fs.existsSync(firstController.paths.root)).toBe(false); + expect(database.getAccount(second.id)).not.toBeNull(); + expect(fs.existsSync(secondController.paths.root)).toBe(true); + + secondController.close(); + }); +}); diff --git a/src/account_bluesky/__tests__/integration/ipc.deletion.integration.test.ts b/src/account_bluesky/__tests__/integration/ipc.deletion.integration.test.ts new file mode 100644 index 00000000..155c82e4 --- /dev/null +++ b/src/account_bluesky/__tests__/integration/ipc.deletion.integration.test.ts @@ -0,0 +1,79 @@ +import fs from "fs"; +import os from "os"; +import path from "path"; + +import { afterEach, beforeEach, expect, test, vi } from "vitest"; + +const electronMocks = vi.hoisted(() => ({ + handlers: new Map unknown>(), + fromPartition: vi.fn(), + closeAllConnections: vi.fn(), + clearStorageData: vi.fn(), +})); + +vi.mock("electron", () => ({ + app: { + getPath: vi.fn(() => ""), + getVersion: vi.fn(() => "0.0.1"), + }, + ipcMain: { + handle: vi.fn( + (channel: string, handler: (...args: unknown[]) => unknown) => { + electronMocks.handlers.set(channel, handler); + }, + ), + }, + session: { + fromPartition: electronMocks.fromPartition.mockReturnValue({ + closeAllConnections: electronMocks.closeAllConnections, + clearStorageData: electronMocks.clearStorageData, + }), + }, +})); + +import * as database from "../../../database"; +import { BlueskyLocalAccountController } from "../../bluesky_account_controller"; + +let temporaryRoot: string; + +beforeEach(() => { + temporaryRoot = fs.mkdtempSync(path.join(os.tmpdir(), "cyd-bluesky-ipc-")); + process.env.TEST_MODE = "1"; + process.env.TEST_SETTINGS_PATH = path.join(temporaryRoot, "settings"); + process.env.TEST_DATA_PATH = path.join(temporaryRoot, "data"); + database.getMainDatabase(); + database.runMainMigrations(); + electronMocks.handlers.clear(); + vi.clearAllMocks(); +}); + +afterEach(() => { + database.closeMainDatabase(); + fs.rmSync(temporaryRoot, { recursive: true, force: true }); +}); + +test("confirmed deletion clears the account webview partition and local root", async () => { + const account = database.selectAccountType( + database.createAccount().id, + "Bluesky", + ); + const controller = new BlueskyLocalAccountController(account.id); + controller.open(); + controller.storeMedia(Buffer.from("private media")); + const localRoot = controller.paths.root; + controller.close(); + + database.defineIPCDatabaseAccount(); + const deleteHandler = electronMocks.handlers.get("database:deleteAccount"); + expect(deleteHandler).toBeDefined(); + + await deleteHandler!({}, account.id, account.uuid); + + expect(electronMocks.fromPartition).toHaveBeenCalledWith( + `persist:account-${account.id}`, + ); + expect(electronMocks.closeAllConnections).toHaveBeenCalledOnce(); + expect(electronMocks.clearStorageData).toHaveBeenCalledOnce(); + expect(fs.existsSync(localRoot)).toBe(false); + expect(database.getAccount(account.id)).toBeNull(); +}); diff --git a/src/account_bluesky/bluesky_account_controller.ts b/src/account_bluesky/bluesky_account_controller.ts new file mode 100644 index 00000000..a6c001ce --- /dev/null +++ b/src/account_bluesky/bluesky_account_controller.ts @@ -0,0 +1,183 @@ +import fs from "fs"; +import path from "path"; +import { createHash } from "crypto"; + +import Database from "better-sqlite3"; + +import { + deleteAccount, + getAccount, + runMigrations, + saveBlueskyLocalAccount, +} from "../database"; +import type { BlueskyLocalAccount } from "../shared_types"; +import { getDataPath } from "../util"; + +export interface BlueskyLocalAccountPaths { + root: string; + database: string; + media: string; + staging: string; +} + +export type BlueskyProfileUpdate = Partial< + Pick +>; + +export interface BlueskyJobState { + id: string; + jobType: string; + status: string; + progress: unknown; +} + +export interface BlueskyConnectionStore { + delete(uuid: string): Promise; +} + +const runtimeMigrations = [ + { + name: "initial Bluesky runtime storage", + sql: [ + `CREATE TABLE job ( + id TEXT PRIMARY KEY, + type TEXT NOT NULL, + state TEXT NOT NULL, + progressJson TEXT NOT NULL DEFAULT '{}', + updatedAt DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +);`, + ], + }, +]; + +const ensurePrivateDirectory = (directory: string): void => { + fs.mkdirSync(directory, { recursive: true, mode: 0o700 }); + fs.chmodSync(directory, 0o700); +}; + +export class BlueskyLocalAccountController { + public account!: BlueskyLocalAccount; + public paths!: BlueskyLocalAccountPaths; + private db: Database.Database | null = null; + + constructor( + private readonly accountID: number, + private readonly connectionStore?: BlueskyConnectionStore, + ) {} + + open(): void { + const account = getAccount(this.accountID); + if ( + !account || + account.type !== "Bluesky" || + !account.blueskyLocalAccount + ) { + throw new Error(`Bluesky local account ${this.accountID} not found`); + } + this.account = account.blueskyLocalAccount; + + const root = path.join(getDataPath(), "Bluesky", account.uuid); + this.paths = { + root, + database: path.join(root, "runtime.sqlite3"), + media: path.join(root, "media", "sha256"), + staging: path.join(root, "staging"), + }; + ensurePrivateDirectory(this.paths.root); + ensurePrivateDirectory(this.paths.media); + ensurePrivateDirectory(this.paths.staging); + + this.db = new Database(this.paths.database); + fs.chmodSync(this.paths.database, 0o600); + this.db.pragma("journal_mode = WAL"); + runMigrations(this.db, runtimeMigrations); + } + + bindDid(did: string): void { + this.assertOpen(); + const updatedAccount = { ...this.account, did }; + saveBlueskyLocalAccount(updatedAccount); + this.account = updatedAccount; + } + + updateProfile(update: BlueskyProfileUpdate): void { + this.assertOpen(); + const updatedAccount = { ...this.account, ...update }; + saveBlueskyLocalAccount(updatedAccount); + this.account = updatedAccount; + } + + storeMedia(content: Buffer): { digest: string; path: string } { + this.assertOpen(); + const digest = createHash("sha256").update(content).digest("hex"); + const mediaPath = path.join(this.paths.media, digest); + try { + fs.writeFileSync(mediaPath, content, { flag: "wx", mode: 0o600 }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EEXIST") { + throw error; + } + } + fs.chmodSync(mediaPath, 0o600); + return { digest, path: mediaPath }; + } + + saveJobState(job: BlueskyJobState): void { + this.assertOpen(); + this.db!.prepare( + `INSERT INTO job (id, type, state, progressJson) + VALUES (?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + type = excluded.type, + state = excluded.state, + progressJson = excluded.progressJson, + updatedAt = CURRENT_TIMESTAMP`, + ).run(job.id, job.jobType, job.status, JSON.stringify(job.progress)); + } + + getJobState(id: string): BlueskyJobState | null { + this.assertOpen(); + const row = this.db!.prepare( + "SELECT id, type, state, progressJson FROM job WHERE id = ?", + ).get(id) as + | { id: string; type: string; state: string; progressJson: string } + | undefined; + return row + ? { + id: row.id, + jobType: row.type, + status: row.state, + progress: JSON.parse(row.progressJson), + } + : null; + } + + async deleteConfirmed(confirmationUuid: string): Promise { + this.assertOpen(); + if (confirmationUuid !== this.account.uuid) { + throw new Error("Bluesky local account deletion confirmation mismatch"); + } + if (!this.connectionStore) { + throw new Error("A Bluesky connection store is required for deletion"); + } + + await this.connectionStore.delete(this.account.uuid); + this.close(); + fs.rmSync(this.paths.root, { recursive: true, force: true }); + deleteAccount(this.accountID, confirmationUuid); + } + + close(): void { + if (this.db) { + this.db.pragma("wal_checkpoint(FULL)"); + this.db.close(); + this.db = null; + } + } + + private assertOpen(): void { + if (!this.db) { + throw new Error("Bluesky local account is not open"); + } + } +} diff --git a/src/account_bluesky/index.ts b/src/account_bluesky/index.ts new file mode 100644 index 00000000..d614b0c7 --- /dev/null +++ b/src/account_bluesky/index.ts @@ -0,0 +1,2 @@ +export { defineIPCBluesky } from "./ipc"; +export { BlueskyLocalAccountController } from "./bluesky_account_controller"; diff --git a/src/account_bluesky/ipc.ts b/src/account_bluesky/ipc.ts new file mode 100644 index 00000000..45263523 --- /dev/null +++ b/src/account_bluesky/ipc.ts @@ -0,0 +1,17 @@ +import { ipcMain } from "electron"; + +import { packageExceptionForReport } from "../util"; +import { BlueskyLocalAccountController } from "./bluesky_account_controller"; + +export const defineIPCBluesky = (): void => { + ipcMain.handle("Bluesky:openLocalAccount", async (_, accountID: number) => { + const controller = new BlueskyLocalAccountController(accountID); + try { + controller.open(); + controller.close(); + } catch (error) { + controller.close(); + throw new Error(packageExceptionForReport(error as Error)); + } + }); +}; diff --git a/src/database.test.ts b/src/database.test.ts index 7d834772..22dab0cc 100644 --- a/src/database.test.ts +++ b/src/database.test.ts @@ -50,7 +50,7 @@ afterEach(() => { // database tests -test("config, account, xAccount, blueskyAccount, facebookAccount tables should be created", async () => { +test("config, account, xAccount, blueskyLocalAccount, facebookAccount tables should be created", async () => { const db = database.getMainDatabase(); const tables = await database.exec( db, @@ -63,7 +63,7 @@ test("config, account, xAccount, blueskyAccount, facebookAccount tables should b expect.objectContaining({ name: "config" }), expect.objectContaining({ name: "account" }), expect.objectContaining({ name: "xAccount" }), - expect.objectContaining({ name: "blueskyAccount" }), + expect.objectContaining({ name: "blueskyLocalAccount" }), expect.objectContaining({ name: "facebookAccount" }), ]), ); @@ -157,36 +157,17 @@ test("getXAccounts should retrieve all XAccounts", () => { expect(accounts).toEqual(expect.arrayContaining([xAccount1, xAccount2])); }); -test("createBlueskyAccount should create a new BlueskyAccount", () => { - const blueskyAccount = database.createBlueskyAccount(); - expect(blueskyAccount).toHaveProperty("id"); +test("createBlueskyLocalAccount should create a BlueskyLocalAccount", () => { + const account = database.createAccount(); + const blueskyAccount = database.createBlueskyLocalAccount(account.uuid); + expect(blueskyAccount).toHaveProperty("uuid", account.uuid); expect(blueskyAccount).toHaveProperty("createdAt"); expect(blueskyAccount).toHaveProperty("updatedAt"); expect(blueskyAccount).toHaveProperty("accessedAt"); - expect(blueskyAccount).toHaveProperty("username"); - expect(blueskyAccount).toHaveProperty("profileImageDataURI"); - expect(blueskyAccount).toHaveProperty("saveMyData"); - expect(blueskyAccount).toHaveProperty("deleteMyData"); - expect(blueskyAccount).toHaveProperty("archivePosts"); - expect(blueskyAccount).toHaveProperty("archivePostsHTML"); - expect(blueskyAccount).toHaveProperty("archiveLikes"); - expect(blueskyAccount).toHaveProperty("deletePosts"); - expect(blueskyAccount).toHaveProperty("deletePostsDaysOld"); - expect(blueskyAccount).toHaveProperty("deletePostsDaysOldEnabled"); - expect(blueskyAccount).toHaveProperty("deletePostsLikesThresholdEnabled"); - expect(blueskyAccount).toHaveProperty("deletePostsLikesThreshold"); - expect(blueskyAccount).toHaveProperty("deletePostsRepostsThresholdEnabled"); - expect(blueskyAccount).toHaveProperty("deletePostsRepostsThreshold"); - expect(blueskyAccount).toHaveProperty("deleteReposts"); - expect(blueskyAccount).toHaveProperty("deleteRepostsDaysOld"); - expect(blueskyAccount).toHaveProperty("deleteRepostsDaysOldEnabled"); - expect(blueskyAccount).toHaveProperty("deleteLikes"); - expect(blueskyAccount).toHaveProperty("deleteLikesDaysOld"); - expect(blueskyAccount).toHaveProperty("deleteLikesDaysOldEnabled"); - expect(blueskyAccount).toHaveProperty("followingCount"); - expect(blueskyAccount).toHaveProperty("followersCount"); - expect(blueskyAccount).toHaveProperty("postsCount"); - expect(blueskyAccount).toHaveProperty("likesCount"); + expect(blueskyAccount).toHaveProperty("did", null); + expect(blueskyAccount).toHaveProperty("handle", ""); + expect(blueskyAccount).toHaveProperty("displayName", ""); + expect(blueskyAccount).toHaveProperty("avatarUrl", ""); }); test("createFacebookAccount should create a new FacebookAccount", () => { @@ -200,19 +181,20 @@ test("createFacebookAccount should create a new FacebookAccount", () => { expect(facebookAccount).toHaveProperty("accountID"); }); -test("saveBlueskyAccount should update an existing BlueskyAccount", () => { - const blueskyAccount = database.createBlueskyAccount(); - blueskyAccount.username = "newUsername"; - database.saveBlueskyAccount(blueskyAccount); +test("saveBlueskyLocalAccount should update an existing BlueskyLocalAccount", () => { + const account = database.createAccount(); + const blueskyAccount = database.createBlueskyLocalAccount(account.uuid); + blueskyAccount.handle = "new.handle"; + database.saveBlueskyLocalAccount(blueskyAccount); const db = database.getMainDatabase(); const result = database.exec( db, - "SELECT * FROM blueskyAccount WHERE id = ?", - [blueskyAccount.id], + "SELECT * FROM blueskyLocalAccount WHERE uuid = ?", + [blueskyAccount.uuid], "get", ); - expect(result).toEqual(expect.objectContaining({ username: "newUsername" })); + expect(result).toEqual(expect.objectContaining({ handle: "new.handle" })); }); test("saveFacebookAccount should update an existing FacebookAccount", () => { @@ -236,21 +218,26 @@ test("saveFacebookAccount should update an existing FacebookAccount", () => { ); }); -test("getBlueskyAccount should retrieve the correct BlueskyAccount", () => { - const blueskyAccount = database.createBlueskyAccount(); - database.saveBlueskyAccount(blueskyAccount); +test("getBlueskyLocalAccount should retrieve the correct local account", () => { + const account = database.createAccount(); + const blueskyAccount = database.createBlueskyLocalAccount(account.uuid); + database.saveBlueskyLocalAccount(blueskyAccount); - const retrievedAccount = database.getBlueskyAccount(blueskyAccount.id); + const retrievedAccount = database.getBlueskyLocalAccount(blueskyAccount.uuid); expect(retrievedAccount).toEqual(blueskyAccount); }); -test("getBlueskyAccounts should retrieve all BlueskyAccounts", () => { - const blueskyAccount1 = database.createBlueskyAccount(); - const blueskyAccount2 = database.createBlueskyAccount(); - database.saveBlueskyAccount(blueskyAccount1); - database.saveBlueskyAccount(blueskyAccount2); +test("getBlueskyLocalAccounts should retrieve all local accounts", () => { + const blueskyAccount1 = database.createBlueskyLocalAccount( + database.createAccount().uuid, + ); + const blueskyAccount2 = database.createBlueskyLocalAccount( + database.createAccount().uuid, + ); + database.saveBlueskyLocalAccount(blueskyAccount1); + database.saveBlueskyLocalAccount(blueskyAccount2); - const accounts = database.getBlueskyAccounts(); + const accounts = database.getBlueskyLocalAccounts(); expect(accounts).toEqual( expect.arrayContaining([blueskyAccount1, blueskyAccount2]), ); diff --git a/src/database/__tests__/bluesky_local_account_migration.test.ts b/src/database/__tests__/bluesky_local_account_migration.test.ts new file mode 100644 index 00000000..d48a48a6 --- /dev/null +++ b/src/database/__tests__/bluesky_local_account_migration.test.ts @@ -0,0 +1,94 @@ +import Database from "better-sqlite3"; +import { afterEach, describe, expect, test } from "vitest"; + +import { replaceDormantBlueskyAccountsMigration } from "../migrations"; +import { runMigrations } from "../common"; + +describe("Bluesky local account forward migration", () => { + let db: Database.Database | null = null; + + afterEach(() => { + db?.close(); + db = null; + }); + + test("replaces the abandoned model without adapting its rows", () => { + db = new Database(":memory:"); + db.exec(`CREATE TABLE account ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + type TEXT NOT NULL DEFAULT 'unknown', + sortOrder INTEGER NOT NULL DEFAULT 0, + xAccountId INTEGER DEFAULT NULL, + uuid TEXT NOT NULL, + blueskyAccountID INTEGER DEFAULT NULL, + facebookAccountID INTEGER DEFAULT NULL + ); + CREATE TABLE blueskyAccount ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT + );`); + + db.prepare( + "INSERT INTO blueskyAccount (username) VALUES ('abandoned.test')", + ).run(); + db.prepare( + `INSERT INTO account + (type, sortOrder, blueskyAccountID, uuid) + VALUES ('Bluesky', 0, 1, '00000000-0000-4000-8000-000000000001')`, + ).run(); + db.prepare( + `INSERT INTO account + (type, sortOrder, uuid) + VALUES ('X', 1, '00000000-0000-4000-8000-000000000002')`, + ).run(); + + runMigrations(db, [replaceDormantBlueskyAccountsMigration]); + + const tables = db + .prepare("SELECT name FROM sqlite_master WHERE type = 'table'") + .all() as Array<{ name: string }>; + expect(tables.map(({ name }) => name)).not.toContain("blueskyAccount"); + expect(tables.map(({ name }) => name)).toContain("blueskyLocalAccount"); + + const accountColumns = db + .prepare("PRAGMA table_info(account)") + .all() as Array<{ + name: string; + }>; + expect(accountColumns.map(({ name }) => name)).not.toContain( + "blueskyAccountID", + ); + expect(db.prepare("SELECT type, uuid FROM account").all()).toEqual([ + { + type: "X", + uuid: "00000000-0000-4000-8000-000000000002", + }, + ]); + + db.prepare( + `INSERT INTO account (type, sortOrder, uuid) VALUES + ('Bluesky', 2, '00000000-0000-4000-8000-000000000003'), + ('Bluesky', 3, '00000000-0000-4000-8000-000000000004')`, + ).run(); + db.prepare( + `INSERT INTO blueskyLocalAccount (uuid, did, handle) + VALUES (?, ?, ?)`, + ).run( + "00000000-0000-4000-8000-000000000003", + "did:plc:alice", + "alice.test", + ); + expect(() => + db! + .prepare( + `INSERT INTO blueskyLocalAccount (uuid, did, handle) + VALUES (?, ?, ?)`, + ) + .run( + "00000000-0000-4000-8000-000000000004", + "did:plc:alice", + "renamed.test", + ), + ).toThrow(); + }); +}); diff --git a/src/database/account.ts b/src/database/account.ts index e9dbbe4a..b78aacdb 100644 --- a/src/database/account.ts +++ b/src/database/account.ts @@ -3,9 +3,9 @@ import { ipcMain, session } from "electron"; import { exec, getMainDatabase, Sqlite3Info } from "./common"; import { createXAccount, getXAccount, saveXAccount } from "./x_account"; import { - createBlueskyAccount, - getBlueskyAccount, - saveBlueskyAccount, + createBlueskyLocalAccount, + getBlueskyLocalAccount, + saveBlueskyLocalAccount, } from "./bluesky_account"; import { createFacebookAccount, @@ -15,7 +15,7 @@ import { import { Account, XAccount, - BlueskyAccount, + BlueskyLocalAccount, FacebookAccount, } from "../shared_types"; import { packageExceptionForReport } from "../util"; @@ -27,14 +27,13 @@ interface AccountRow { type: string; sortOrder: number; xAccountId: number | null; - blueskyAccountID: number | null; facebookAccountID: number | null; uuid: string; } function accountFromAccountRow(row: AccountRow): Account { let xAccount: XAccount | null = null; - let blueskyAccount: BlueskyAccount | null = null; + let blueskyLocalAccount: BlueskyLocalAccount | null = null; let facebookAccount: FacebookAccount | null = null; switch (row.type) { case "X": @@ -44,9 +43,7 @@ function accountFromAccountRow(row: AccountRow): Account { break; case "Bluesky": - if (row.blueskyAccountID) { - blueskyAccount = getBlueskyAccount(row.blueskyAccountID); - } + blueskyLocalAccount = getBlueskyLocalAccount(row.uuid); break; case "Facebook": @@ -61,7 +58,7 @@ function accountFromAccountRow(row: AccountRow): Account { type: row.type, sortOrder: row.sortOrder, xAccount: xAccount, - blueskyAccount: blueskyAccount, + blueskyLocalAccount, facebookAccount: facebookAccount, uuid: row.uuid, }; @@ -88,8 +85,8 @@ export async function getAccountUsername( ): Promise { if (account.type == "X" && account.xAccount) { return account.xAccount?.username; - } else if (account.type == "Bluesky" && account.blueskyAccount) { - return account.blueskyAccount?.username; + } else if (account.type == "Bluesky" && account.blueskyLocalAccount) { + return account.blueskyLocalAccount.handle; } else if (account.type == "Facebook" && account.facebookAccount) { return account.facebookAccount?.username; } @@ -155,7 +152,7 @@ export const selectAccountType = (accountID: number, type: string): Account => { account.xAccount = createXAccount(); break; case "Bluesky": - account.blueskyAccount = createBlueskyAccount(); + account.blueskyLocalAccount = createBlueskyLocalAccount(account.uuid); break; case "Facebook": account.facebookAccount = createFacebookAccount(); @@ -165,9 +162,6 @@ export const selectAccountType = (accountID: number, type: string): Account => { } const xAccountId = account.xAccount ? account.xAccount.id : null; - const blueskyAccountID = account.blueskyAccount - ? account.blueskyAccount.id - : null; const facebookAccountID = account.facebookAccount ? account.facebookAccount.id : null; @@ -180,11 +174,10 @@ export const selectAccountType = (accountID: number, type: string): Account => { SET type = ?, xAccountId = ?, - blueskyAccountID = ?, facebookAccountID = ? WHERE id = ? `, - [type, xAccountId, blueskyAccountID, facebookAccountID, account.id], + [type, xAccountId, facebookAccountID, account.id], ); account.type = type; @@ -195,8 +188,8 @@ export const selectAccountType = (accountID: number, type: string): Account => { export const saveAccount = (account: Account) => { if (account.xAccount) { saveXAccount(account.xAccount); - } else if (account.blueskyAccount) { - saveBlueskyAccount(account.blueskyAccount); + } else if (account.blueskyLocalAccount) { + saveBlueskyLocalAccount(account.blueskyLocalAccount); } else if (account.facebookAccount) { saveFacebookAccount(account.facebookAccount); } @@ -214,12 +207,15 @@ export const saveAccount = (account: Account) => { ); }; -export const deleteAccount = (accountID: number) => { +export const deleteAccount = (accountID: number, confirmationUuid?: string) => { // Get the account const account = getAccount(accountID); if (!account) { throw new Error("Account not found"); } + if (account.type === "Bluesky" && confirmationUuid !== account.uuid) { + throw new Error("Bluesky local account deletion confirmation mismatch"); + } // Delete the account type switch (account.type) { @@ -231,10 +227,12 @@ export const deleteAccount = (accountID: number) => { } break; case "Bluesky": - if (account.blueskyAccount) { - exec(getMainDatabase(), "DELETE FROM blueskyAccount WHERE id = ?", [ - account.blueskyAccount.id, - ]); + if (account.blueskyLocalAccount) { + exec( + getMainDatabase(), + "DELETE FROM blueskyLocalAccount WHERE uuid = ?", + [account.blueskyLocalAccount.uuid], + ); } break; case "Facebook": @@ -297,14 +295,32 @@ export const defineIPCDatabaseAccount = () => { } }); - ipcMain.handle("database:deleteAccount", async (_, accountID) => { - try { - const ses = session.fromPartition(`persist:account-${accountID}`); - await ses.closeAllConnections(); - await ses.clearStorageData(); - deleteAccount(accountID); - } catch (error) { - throw new Error(packageExceptionForReport(error as Error)); - } - }); + ipcMain.handle( + "database:deleteAccount", + async (_, accountID, confirmationUuid?: string) => { + try { + const account = getAccount(accountID); + if (account?.type === "Bluesky") { + const ses = session.fromPartition(`persist:account-${accountID}`); + const { BlueskyLocalAccountController } = + await import("../account_bluesky/bluesky_account_controller"); + const controller = new BlueskyLocalAccountController(accountID, { + delete: async () => { + await ses.closeAllConnections(); + await ses.clearStorageData(); + }, + }); + controller.open(); + await controller.deleteConfirmed(confirmationUuid ?? ""); + } else { + const ses = session.fromPartition(`persist:account-${accountID}`); + await ses.closeAllConnections(); + await ses.clearStorageData(); + deleteAccount(accountID); + } + } catch (error) { + throw new Error(packageExceptionForReport(error as Error)); + } + }, + ); }; diff --git a/src/database/bluesky_account.ts b/src/database/bluesky_account.ts index 49bbbd66..7b3dd697 100644 --- a/src/database/bluesky_account.ts +++ b/src/database/bluesky_account.ts @@ -1,205 +1,84 @@ -import { exec, getMainDatabase, Sqlite3Info } from "./common"; -import { BlueskyAccount } from "../shared_types"; +import { exec, getMainDatabase } from "./common"; +import { BlueskyLocalAccount } from "../shared_types"; -// Types - -export interface BlueskyAccountRow { - id: number; +interface BlueskyLocalAccountRow { + uuid: string; createdAt: string; updatedAt: string; accessedAt: string; - username: string; - profileImageDataURI: string; - saveMyData: boolean; - deleteMyData: boolean; - archivePosts: boolean; - archivePostsHTML: boolean; - archiveLikes: boolean; - deletePosts: boolean; - deletePostsDaysOldEnabled: boolean; - deletePostsDaysOld: number; - deletePostsLikesThresholdEnabled: boolean; - deletePostsLikesThreshold: number; - deletePostsRepostsThresholdEnabled: boolean; - deletePostsRepostsThreshold: number; - deleteReposts: boolean; - deleteRepostsDaysOldEnabled: boolean; - deleteRepostsDaysOld: number; - deleteLikes: boolean; - deleteLikesDaysOldEnabled: boolean; - deleteLikesDaysOld: number; - followingCount: number; - followersCount: number; - postsCount: number; - likesCount: number; + did: string | null; + handle: string; + displayName: string; + avatarUrl: string; } -// Functions +const accountFromRow = (row: BlueskyLocalAccountRow): BlueskyLocalAccount => ({ + uuid: row.uuid, + createdAt: new Date(row.createdAt), + updatedAt: new Date(row.updatedAt), + accessedAt: new Date(row.accessedAt), + did: row.did, + handle: row.handle, + displayName: row.displayName, + avatarUrl: row.avatarUrl, +}); -// Get a single Bluesky account by ID -export const getBlueskyAccount = (id: number): BlueskyAccount | null => { - const row: BlueskyAccountRow | undefined = exec( +export const getBlueskyLocalAccount = ( + uuid: string, +): BlueskyLocalAccount | null => { + const row = exec( getMainDatabase(), - "SELECT * FROM blueskyAccount WHERE id = ?", - [id], + "SELECT * FROM blueskyLocalAccount WHERE uuid = ?", + [uuid], "get", - ) as BlueskyAccountRow | undefined; - if (!row) { - return null; - } - return { - id: row.id, - createdAt: new Date(row.createdAt), - updatedAt: new Date(row.updatedAt), - accessedAt: new Date(row.accessedAt), - username: row.username, - profileImageDataURI: row.profileImageDataURI, - saveMyData: !!row.saveMyData, - deleteMyData: !!row.deleteMyData, - archivePosts: !!row.archivePosts, - archivePostsHTML: !!row.archivePostsHTML, - archiveLikes: !!row.archiveLikes, - deletePosts: !!row.deletePosts, - deletePostsDaysOldEnabled: !!row.deletePostsDaysOldEnabled, - deletePostsDaysOld: row.deletePostsDaysOld, - deletePostsLikesThresholdEnabled: !!row.deletePostsLikesThresholdEnabled, - deletePostsLikesThreshold: row.deletePostsLikesThreshold, - deletePostsRepostsThresholdEnabled: - !!row.deletePostsRepostsThresholdEnabled, - deletePostsRepostsThreshold: row.deletePostsRepostsThreshold, - deleteReposts: !!row.deleteReposts, - deleteRepostsDaysOldEnabled: !!row.deleteRepostsDaysOldEnabled, - deleteRepostsDaysOld: row.deleteRepostsDaysOld, - deleteLikes: !!row.deleteLikes, - deleteLikesDaysOldEnabled: !!row.deleteLikesDaysOldEnabled, - deleteLikesDaysOld: row.deleteLikesDaysOld, - followingCount: row.followingCount, - followersCount: row.followersCount, - postsCount: row.postsCount, - likesCount: row.likesCount, - }; + ) as BlueskyLocalAccountRow | undefined; + return row ? accountFromRow(row) : null; }; -// Get all Bluesky accounts -export const getBlueskyAccounts = (): BlueskyAccount[] => { - const rows: BlueskyAccountRow[] = exec( +export const getBlueskyLocalAccounts = (): BlueskyLocalAccount[] => { + const rows = exec( getMainDatabase(), - "SELECT * FROM blueskyAccount", + "SELECT * FROM blueskyLocalAccount", [], "all", - ) as BlueskyAccountRow[]; - - const accounts: BlueskyAccount[] = []; - for (const row of rows) { - accounts.push({ - id: row.id, - createdAt: new Date(row.createdAt), - updatedAt: new Date(row.updatedAt), - accessedAt: new Date(row.accessedAt), - username: row.username, - profileImageDataURI: row.profileImageDataURI, - saveMyData: !!row.saveMyData, - deleteMyData: !!row.deleteMyData, - archivePosts: !!row.archivePosts, - archivePostsHTML: !!row.archivePostsHTML, - archiveLikes: !!row.archiveLikes, - deletePosts: !!row.deletePosts, - deletePostsDaysOldEnabled: !!row.deletePostsDaysOldEnabled, - deletePostsDaysOld: row.deletePostsDaysOld, - deletePostsLikesThresholdEnabled: !!row.deletePostsLikesThresholdEnabled, - deletePostsLikesThreshold: row.deletePostsLikesThreshold, - deletePostsRepostsThresholdEnabled: - !!row.deletePostsRepostsThresholdEnabled, - deletePostsRepostsThreshold: row.deletePostsRepostsThreshold, - deleteReposts: !!row.deleteReposts, - deleteRepostsDaysOldEnabled: !!row.deleteRepostsDaysOldEnabled, - deleteRepostsDaysOld: row.deleteRepostsDaysOld, - deleteLikes: !!row.deleteLikes, - deleteLikesDaysOldEnabled: !!row.deleteLikesDaysOldEnabled, - deleteLikesDaysOld: row.deleteLikesDaysOld, - followingCount: row.followingCount, - followersCount: row.followersCount, - postsCount: row.postsCount, - likesCount: row.likesCount, - }); - } - return accounts; + ) as BlueskyLocalAccountRow[]; + return rows.map(accountFromRow); }; -// Create a new Bluesky account -export const createBlueskyAccount = (): BlueskyAccount => { - const info: Sqlite3Info = exec( - getMainDatabase(), - "INSERT INTO blueskyAccount DEFAULT VALUES", - ) as Sqlite3Info; - const account = getBlueskyAccount(info.lastInsertRowid); +export const createBlueskyLocalAccount = ( + uuid: string, +): BlueskyLocalAccount => { + exec(getMainDatabase(), "INSERT INTO blueskyLocalAccount (uuid) VALUES (?)", [ + uuid, + ]); + const account = getBlueskyLocalAccount(uuid); if (!account) { - throw new Error("Failed to create account"); + throw new Error("Failed to create Bluesky local account"); } return account; }; -// Update the Bluesky account based on account.id -export const saveBlueskyAccount = (account: BlueskyAccount) => { +export const saveBlueskyLocalAccount = (account: BlueskyLocalAccount): void => { + const storedAccount = getBlueskyLocalAccount(account.uuid); + if (storedAccount?.did && storedAccount.did !== account.did) { + throw new Error("A Bluesky local account DID cannot be changed"); + } exec( getMainDatabase(), - ` - UPDATE blueskyAccount - SET - updatedAt = CURRENT_TIMESTAMP, - accessedAt = CURRENT_TIMESTAMP, - username = ?, - profileImageDataURI = ?, - saveMyData = ?, - deleteMyData = ?, - archivePosts = ?, - archivePostsHTML = ?, - archiveLikes = ?, - deletePosts = ?, - deletePostsDaysOld = ?, - deletePostsDaysOldEnabled = ?, - deletePostsLikesThresholdEnabled = ?, - deletePostsLikesThreshold = ?, - deletePostsRepostsThresholdEnabled = ?, - deletePostsRepostsThreshold = ?, - deleteReposts = ?, - deleteRepostsDaysOldEnabled = ?, - deleteRepostsDaysOld = ?, - deleteLikes = ?, - deleteLikesDaysOldEnabled = ?, - deleteLikesDaysOld = ?, - followingCount = ?, - followersCount = ?, - postsCount = ?, - likesCount = ? - WHERE id = ? - `, + `UPDATE blueskyLocalAccount + SET updatedAt = CURRENT_TIMESTAMP, + accessedAt = CURRENT_TIMESTAMP, + did = ?, + handle = ?, + displayName = ?, + avatarUrl = ? + WHERE uuid = ?`, [ - account.username, - account.profileImageDataURI, - account.saveMyData ? 1 : 0, - account.deleteMyData ? 1 : 0, - account.archivePosts ? 1 : 0, - account.archivePostsHTML ? 1 : 0, - account.archiveLikes ? 1 : 0, - account.deletePosts ? 1 : 0, - account.deletePostsDaysOld, - account.deletePostsDaysOldEnabled ? 1 : 0, - account.deletePostsLikesThresholdEnabled ? 1 : 0, - account.deletePostsLikesThreshold, - account.deletePostsRepostsThresholdEnabled ? 1 : 0, - account.deletePostsRepostsThreshold, - account.deleteReposts ? 1 : 0, - account.deleteRepostsDaysOldEnabled ? 1 : 0, - account.deleteRepostsDaysOld, - account.deleteLikes ? 1 : 0, - account.deleteLikesDaysOldEnabled ? 1 : 0, - account.deleteLikesDaysOld, - account.followingCount, - account.followersCount, - account.postsCount, - account.likesCount, - account.id, + account.did, + account.handle, + account.displayName, + account.avatarUrl, + account.uuid, ], ); }; diff --git a/src/database/migrations.ts b/src/database/migrations.ts index 6de5f33a..11414ede 100644 --- a/src/database/migrations.ts +++ b/src/database/migrations.ts @@ -1,4 +1,37 @@ -import { runMigrations, getMainDatabase } from "./common"; +import { runMigrations, getMainDatabase, Migration } from "./common"; + +export const replaceDormantBlueskyAccountsMigration: Migration = { + name: "replace dormant Bluesky accounts", + sql: [ + `DELETE FROM account WHERE type = 'Bluesky';`, + `DROP TABLE IF EXISTS blueskyAccount;`, + `CREATE TABLE accountReplacement ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + type TEXT NOT NULL DEFAULT 'unknown', + sortOrder INTEGER NOT NULL DEFAULT 0, + xAccountId INTEGER DEFAULT NULL, + uuid TEXT NOT NULL, + facebookAccountID INTEGER DEFAULT NULL +);`, + `INSERT INTO accountReplacement + (id, type, sortOrder, xAccountId, uuid, facebookAccountID) +SELECT id, type, sortOrder, xAccountId, uuid, facebookAccountID FROM account;`, + `DROP TABLE account;`, + `ALTER TABLE accountReplacement RENAME TO account;`, + `CREATE UNIQUE INDEX accountUuid ON account(uuid);`, + `CREATE TABLE blueskyLocalAccount ( + uuid TEXT PRIMARY KEY, + createdAt DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updatedAt DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + accessedAt DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + did TEXT UNIQUE, + handle TEXT NOT NULL DEFAULT '', + displayName TEXT NOT NULL DEFAULT '', + avatarUrl TEXT NOT NULL DEFAULT '', + FOREIGN KEY (uuid) REFERENCES account(uuid) ON DELETE CASCADE +);`, + ], +}; export const runMainMigrations = () => { runMigrations(getMainDatabase(), [ @@ -216,5 +249,6 @@ export const runMainMigrations = () => { `ALTER TABLE facebookAccount ADD COLUMN deleteTaggedMedia INTEGER DEFAULT 0;`, ], }, + replaceDormantBlueskyAccountsMigration, ]); }; diff --git a/src/main.ts b/src/main.ts index 5b8f8c6d..f23fa1b4 100644 --- a/src/main.ts +++ b/src/main.ts @@ -23,6 +23,7 @@ import electronSquirrelStartup from "electron-squirrel-startup"; import * as database from "./database"; import { defineIPCX } from "./account_x"; import { defineIPCFacebook } from "./account_facebook"; +import { defineIPCBluesky } from "./account_bluesky"; import { defineIPCArchive } from "./archive"; import { getUpdatesBaseURL, @@ -585,6 +586,16 @@ async function createWindow() { if (!account) { return null; } + if (account.type === "Bluesky") { + const localAccountPath = path.join( + getDataPath(), + "Bluesky", + account.uuid, + ); + return filename === "" + ? localAccountPath + : path.join(localAccountPath, filename); + } const username = await database.getAccountUsername(account); if (!username) { return null; @@ -657,6 +668,7 @@ async function createWindow() { // Other IPC events database.defineIPCDatabase(); + defineIPCBluesky(); defineIPCX(); defineIPCFacebook(); defineIPCArchive(); diff --git a/src/preload.ts b/src/preload.ts index dbb31ab1..15512049 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -187,8 +187,18 @@ const electronAPI = { saveAccount: (accountJSON: string) => { ipcRenderer.invoke("database:saveAccount", accountJSON); }, - deleteAccount: (accountID: number) => { - return ipcRenderer.invoke("database:deleteAccount", accountID); + deleteAccount: (accountID: number, confirmationUuid?: string) => { + return ipcRenderer.invoke( + "database:deleteAccount", + accountID, + confirmationUuid, + ); + }, + }, + + Bluesky: { + openLocalAccount: (accountID: number): Promise => { + return ipcRenderer.invoke("Bluesky:openLocalAccount", accountID); }, }, diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index e5e0a304..6f64201d 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -402,6 +402,7 @@ "removeAccount": "Remove account", "xDescription": "Formerly Twitter, owned by billionaire Elon Musk", "blueskyDescription": "Open source social media platform based on AT Protocol", + "blueskyLocalStorageReady": "Bluesky local account storage is ready.", "facebookDescription": "A subsidiary of Meta, owned by billionaire Mark Zuckerberg", "facebookDevelopmentNote": "Facebook support is under active development.", "blueskyMobilePromo": "Want to clean up your Bluesky data? Use the Cyd mobile app!", diff --git a/src/renderer/src/test_util.ts b/src/renderer/src/test_util.ts index 0f8fbe3c..1fc73dc6 100644 --- a/src/renderer/src/test_util.ts +++ b/src/renderer/src/test_util.ts @@ -106,7 +106,7 @@ export function createMockAccount(overrides?: Partial): Account { type: "X", sortOrder: 0, xAccount: createMockXAccount(), - blueskyAccount: null, + blueskyLocalAccount: null, facebookAccount: null, uuid: "test-uuid-123", ...overrides, @@ -166,6 +166,10 @@ export function mockElectronAPI() { dismissNewErrorReports: vi.fn().mockResolvedValue(undefined), }, + Bluesky: { + openLocalAccount: vi.fn().mockResolvedValue(undefined), + }, + // X operations X: { getDatabaseStats: vi.fn().mockResolvedValue({ diff --git a/src/renderer/src/view_models/XViewModel/view_model.test.ts b/src/renderer/src/view_models/XViewModel/view_model.test.ts index 2175563e..7e9f5f82 100644 --- a/src/renderer/src/view_models/XViewModel/view_model.test.ts +++ b/src/renderer/src/view_models/XViewModel/view_model.test.ts @@ -172,7 +172,7 @@ describe("XViewModel", () => { tombstoneUpdateBioCreditCyd: false, tombstoneLockAccount: false, }, - blueskyAccount: null, + blueskyLocalAccount: null, uuid: "test-uuid-123", }; diff --git a/src/renderer/src/views/AccountView.test.ts b/src/renderer/src/views/AccountView.test.ts index 81c1b823..f06ef1b1 100644 --- a/src/renderer/src/views/AccountView.test.ts +++ b/src/renderer/src/views/AccountView.test.ts @@ -317,6 +317,65 @@ describe("AccountView", () => { }); }); + describe("Bluesky local account type", () => { + it("opens and renders a Bluesky local account", async () => { + const account = createMockAccount({ + type: "Bluesky", + xAccount: null, + blueskyLocalAccount: { + uuid: "00000000-0000-4000-8000-000000000001", + createdAt: new Date(), + updatedAt: new Date(), + accessedAt: new Date(), + did: "did:plc:alice", + handle: "alice.test", + displayName: "Alice", + avatarUrl: "", + }, + }); + + wrapper = mount(AccountView, { + props: { account }, + global: { plugins: [i18n] }, + }); + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(wrapper.find(".bluesky-local-account").text()).toContain("Alice"); + expect(window.electron.Bluesky.openLocalAccount).toHaveBeenCalledWith( + account.id, + ); + }); + + it("initializes storage when an unknown account becomes Bluesky", async () => { + const unknownAccount = createMockAccount({ type: "unknown" }); + wrapper = mount(AccountView, { + props: { account: unknownAccount }, + global: { plugins: [i18n] }, + }); + + await wrapper.setProps({ + account: { + ...unknownAccount, + type: "Bluesky", + blueskyLocalAccount: { + uuid: unknownAccount.uuid, + createdAt: new Date(), + updatedAt: new Date(), + accessedAt: new Date(), + did: null, + handle: "", + displayName: "", + avatarUrl: "", + }, + }, + }); + + expect(window.electron.Bluesky.openLocalAccount).toHaveBeenCalledWith( + unknownAccount.id, + ); + }); + }); + describe("Facebook account type", () => { it("should render FacebookView when account type is Facebook", async () => { const facebookAccount = createMockAccount({ diff --git a/src/renderer/src/views/AccountView.vue b/src/renderer/src/views/AccountView.vue index 8a265102..f37fdccf 100644 --- a/src/renderer/src/views/AccountView.vue +++ b/src/renderer/src/views/AccountView.vue @@ -1,5 +1,5 @@