diff --git a/apps/desktop/electron-builder.ts b/apps/desktop/electron-builder.ts index 8b08ddd17ba..9d2bbcc5ce7 100644 --- a/apps/desktop/electron-builder.ts +++ b/apps/desktop/electron-builder.ts @@ -105,6 +105,9 @@ const config: Configuration = { // Required for macOS microphone permission prompt NSMicrophoneUsageDescription: "Superset needs microphone access so voice-enabled tools like Codex transcription can capture audio input.", + // Required for macOS camera permission prompt + NSCameraUsageDescription: + "Superset needs camera access so websites and tools running inside the app can capture video input.", // Required for macOS local network permission prompt NSLocalNetworkUsageDescription: "Superset needs access to your local network to discover and connect to development servers running on your network.", diff --git a/apps/desktop/src/lib/electron/request-media-access.ts b/apps/desktop/src/lib/electron/request-media-access.ts new file mode 100644 index 00000000000..1e5e9fe157f --- /dev/null +++ b/apps/desktop/src/lib/electron/request-media-access.ts @@ -0,0 +1,50 @@ +import type { SitePermissionKind } from "@superset/local-db"; +import { shell, systemPreferences } from "electron"; + +const MEDIA_ACCESS_SETTINGS_URLS: Record = { + microphone: + "x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone", + camera: + "x-apple.systempreferences:com.apple.preference.security?Privacy_Camera", +}; + +interface RequestMediaAccessResult { + granted: boolean; + openedSystemSettings: boolean; +} + +export async function requestMediaAccess( + kind: SitePermissionKind, +): Promise { + if (process.platform !== "darwin") { + return { + granted: true, + openedSystemSettings: false, + }; + } + + try { + if (systemPreferences.getMediaAccessStatus(kind) === "granted") { + return { + granted: true, + openedSystemSettings: false, + }; + } + + const granted = await systemPreferences.askForMediaAccess(kind); + if (granted) { + return { + granted: true, + openedSystemSettings: false, + }; + } + } catch { + // Fall through to opening System Settings. + } + + await shell.openExternal(MEDIA_ACCESS_SETTINGS_URLS[kind]); + return { + granted: false, + openedSystemSettings: true, + }; +} diff --git a/apps/desktop/src/lib/trpc/routers/browser/browser.ts b/apps/desktop/src/lib/trpc/routers/browser/browser.ts index f2aed8c6bc9..dd74439f930 100644 --- a/apps/desktop/src/lib/trpc/routers/browser/browser.ts +++ b/apps/desktop/src/lib/trpc/routers/browser/browser.ts @@ -1,6 +1,12 @@ +import { + SITE_PERMISSION_KINDS, + SITE_PERMISSION_VALUES, +} from "@superset/local-db"; import { observable } from "@trpc/server/observable"; import { session } from "electron"; +import { requestMediaAccess } from "lib/electron/request-media-access"; import { browserManager } from "main/lib/browser/browser-manager"; +import { browserSitePermissionManager } from "main/lib/browser/browser-site-permission-manager"; import { z } from "zod"; import { publicProcedure, router } from "../.."; @@ -190,6 +196,71 @@ export const createBrowserRouter = () => { }; }), + getSitePermissions: publicProcedure + .input(z.object({ url: z.string() })) + .query(({ input }) => { + return browserSitePermissionManager.getPermissionsForUrl(input.url); + }), + + setSitePermission: publicProcedure + .input( + z.object({ + origin: z.string(), + kind: z.enum(SITE_PERMISSION_KINDS), + value: z.enum(SITE_PERMISSION_VALUES), + }), + ) + .mutation(async ({ input }) => { + const sitePermissions = browserSitePermissionManager.setPermission( + input.origin, + input.kind, + input.value, + ); + + const mediaAccess = + input.value === "allow" ? await requestMediaAccess(input.kind) : null; + + return { + ...sitePermissions, + mediaAccess, + }; + }), + + resetSitePermissions: publicProcedure + .input(z.object({ origin: z.string() })) + .mutation(({ input }) => { + browserSitePermissionManager.resetPermissions(input.origin); + return { success: true }; + }), + + onSitePermissionRequested: publicProcedure + .input(z.object({ paneId: z.string() })) + .subscription(({ input }) => { + return observable<{ + paneId: string; + origin: string; + permissions: ("microphone" | "camera")[]; + }>((emit) => { + const handler = (event: { + paneId: string; + origin: string; + permissions: ("microphone" | "camera")[]; + }) => { + emit.next(event); + }; + browserSitePermissionManager.on( + `permission-requested:${input.paneId}`, + handler, + ); + return () => { + browserSitePermissionManager.off( + `permission-requested:${input.paneId}`, + handler, + ); + }; + }); + }), + clearBrowsingData: publicProcedure .input( z.object({ diff --git a/apps/desktop/src/lib/trpc/routers/changes/utils/existing-pr-push-target.ts b/apps/desktop/src/lib/trpc/routers/changes/utils/existing-pr-push-target.ts index 4f79251f98a..924ec02d4f0 100644 --- a/apps/desktop/src/lib/trpc/routers/changes/utils/existing-pr-push-target.ts +++ b/apps/desktop/src/lib/trpc/routers/changes/utils/existing-pr-push-target.ts @@ -1,29 +1,18 @@ import type { GitHubStatus } from "@superset/local-db"; -import { normalizeGitHubRepoUrl } from "./pull-request-url"; +import { + type GitRemoteInfo, + type GitTrackingRefInfo, + getPullRequestHeadRepoUrl, + isOpenPullRequestState, + type PullRequestPushTargetInfo, + resolveRemoteNameForPullRequestHead, +} from "../../workspaces/utils/github/pr-attachment"; -type ExistingPullRequest = NonNullable; - -export interface GitRemoteInfo { - name: string; - fetchUrl?: string; - pushUrl?: string; -} - -export interface GitTrackingRefInfo { - remoteName: string; - branchName: string; -} +export type { GitRemoteInfo }; -export interface ExistingPullRequestPushTargetInfo { - remote: string; - targetBranch: string; -} - -export function isOpenPullRequestState( - state: ExistingPullRequest["state"], -): boolean { - return state === "open" || state === "draft"; -} +type ExistingPullRequest = NonNullable; +export type ExistingPullRequestPushTargetInfo = PullRequestPushTargetInfo; +export { isOpenPullRequestState }; export function getExistingPRHeadRepoUrl( pr: Pick< @@ -31,15 +20,7 @@ export function getExistingPRHeadRepoUrl( "headRepositoryOwner" | "headRepositoryName" | "isCrossRepository" >, ): string | null { - if ( - !pr.isCrossRepository || - !pr.headRepositoryOwner || - !pr.headRepositoryName - ) { - return null; - } - - return `https://github.com/${pr.headRepositoryOwner}/${pr.headRepositoryName}`; + return getPullRequestHeadRepoUrl(pr); } export function resolveRemoteNameForExistingPRHead({ @@ -54,36 +35,11 @@ export function resolveRemoteNameForExistingPRHead({ >; fallbackRemote: string; }): string | null { - if (!pr.isCrossRepository) { - return fallbackRemote; - } - - const headRepoUrl = getExistingPRHeadRepoUrl(pr); - if (!headRepoUrl) { - return null; - } - - const normalizedHeadRepoUrl = normalizeGitHubRepoUrl(headRepoUrl); - if (!normalizedHeadRepoUrl) { - return null; - } - - for (const remote of remotes) { - const fetchUrl = remote.fetchUrl - ? normalizeGitHubRepoUrl(remote.fetchUrl) - : null; - const pushUrl = remote.pushUrl - ? normalizeGitHubRepoUrl(remote.pushUrl) - : null; - if ( - fetchUrl === normalizedHeadRepoUrl || - pushUrl === normalizedHeadRepoUrl - ) { - return remote.name; - } - } - - return null; + return resolveRemoteNameForPullRequestHead({ + remotes, + pr, + fallbackRemote, + }); } export function shouldRetargetPushToExistingPRHead({ diff --git a/apps/desktop/src/lib/trpc/routers/changes/utils/pull-request-discovery.ts b/apps/desktop/src/lib/trpc/routers/changes/utils/pull-request-discovery.ts index dbd94f3bf69..ebba8ff3ed9 100644 --- a/apps/desktop/src/lib/trpc/routers/changes/utils/pull-request-discovery.ts +++ b/apps/desktop/src/lib/trpc/routers/changes/utils/pull-request-discovery.ts @@ -1,126 +1,26 @@ import { TRPCError } from "@trpc/server"; import type { SimpleGit } from "simple-git"; import { z } from "zod"; -import { execGitWithShellPath } from "../../workspaces/utils/git-client"; -import { getRepoContext } from "../../workspaces/utils/github"; -import { getPullRequestRepoNames } from "../../workspaces/utils/github/repo-context"; +import { fetchGitHubPRStatus } from "../../workspaces/utils/github"; import { execWithShellEnv } from "../../workspaces/utils/shell-env"; import { buildPullRequestCompareUrl, normalizeGitHubRepoUrl, parseUpstreamRef, } from "./pull-request-url"; - -async function findOpenPRByHeadCommit( - worktreePath: string, -): Promise { - try { - const { stdout: headOutput } = await execGitWithShellPath( - ["rev-parse", "HEAD"], - { cwd: worktreePath }, - ); - const headSha = headOutput.trim(); - if (!headSha) { - return null; - } - - const repoNames = getPullRequestRepoNames( - await getRepoContext(worktreePath), - ); - const repoArgSets = - repoNames.length > 0 - ? repoNames.map((repoName) => ["--repo", repoName]) - : [[]]; - - for (const repoArgs of repoArgSets) { - try { - const { stdout } = await execWithShellEnv( - "gh", - [ - "pr", - "list", - ...repoArgs, - "--state", - "open", - "--search", - `${headSha} is:pr`, - "--limit", - "20", - "--json", - "url,headRefOid", - ], - { cwd: worktreePath }, - ); - - const parsed = JSON.parse(stdout) as Array<{ - url?: string; - headRefOid?: string; - }>; - const match = parsed.find( - (candidate) => candidate.headRefOid === headSha, - ); - if (match?.url?.trim()) { - return match.url.trim(); - } - } catch (error) { - console.warn( - "[git/findExistingOpenPRUrl] Failed repo-scoped commit-based PR lookup:", - { - worktreePath, - repoArgs, - message: error instanceof Error ? error.message : String(error), - }, - ); - } - } - - return null; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - console.warn( - "[git/findExistingOpenPRUrl] Failed commit-based PR lookup:", - message, - ); - return null; - } -} +import { clearWorktreeStatusCaches } from "./worktree-status-caches"; export async function findExistingOpenPRUrl( worktreePath: string, ): Promise { - // Prefer tracking-based lookup first for fork/branch-name mismatch scenarios. - try { - const { stdout } = await execWithShellEnv( - "gh", - [ - "pr", - "view", - "--json", - "url,state", - "--jq", - 'if .state == "OPEN" then .url else "" end', - ], - { cwd: worktreePath }, - ); - const url = stdout.trim(); - if (url) { - return url; - } - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - const isNoPROpenError = message - .toLowerCase() - .includes("no pull requests found"); - if (!isNoPROpenError) { - console.warn( - "[git/findExistingOpenPRUrl] Failed tracking-branch PR lookup:", - message, - ); - } - // Fallback to commit-SHA search below. + clearWorktreeStatusCaches(worktreePath); + const githubStatus = await fetchGitHubPRStatus(worktreePath); + const pullRequest = githubStatus?.pr; + if (pullRequest?.state !== "open" && pullRequest?.state !== "draft") { + return null; } - return findOpenPRByHeadCommit(worktreePath); + return pullRequest.url.trim() || null; } const ghRepoMetadataSchema = z.object({ diff --git a/apps/desktop/src/lib/trpc/routers/permissions.ts b/apps/desktop/src/lib/trpc/routers/permissions.ts index 16b9d7d4bc3..e747b327637 100644 --- a/apps/desktop/src/lib/trpc/routers/permissions.ts +++ b/apps/desktop/src/lib/trpc/routers/permissions.ts @@ -2,6 +2,7 @@ import fs from "node:fs"; import { homedir } from "node:os"; import path from "node:path"; import { shell, systemPreferences } from "electron"; +import { requestMediaAccess } from "lib/electron/request-media-access"; import { publicProcedure, router } from ".."; function checkFullDiskAccess(): boolean { @@ -30,6 +31,14 @@ function checkMicrophone(): boolean { } } +function checkCamera(): boolean { + try { + return systemPreferences.getMediaAccessStatus("camera") === "granted"; + } catch { + return false; + } +} + export const createPermissionsRouter = () => { return router({ getStatus: publicProcedure.query(() => { @@ -37,6 +46,7 @@ export const createPermissionsRouter = () => { fullDiskAccess: checkFullDiskAccess(), accessibility: checkAccessibility(), microphone: checkMicrophone(), + camera: checkCamera(), }; }), @@ -53,22 +63,11 @@ export const createPermissionsRouter = () => { }), requestMicrophone: publicProcedure.mutation(async () => { - try { - if (process.platform === "darwin") { - const granted = - await systemPreferences.askForMediaAccess("microphone"); - if (granted) { - return { granted: true }; - } - } - } catch { - // Fall through to opening System Settings. - } + return requestMediaAccess("microphone"); + }), - await shell.openExternal( - "x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone", - ); - return { granted: false }; + requestCamera: publicProcedure.mutation(async () => { + return requestMediaAccess("camera"); }), requestAppleEvents: publicProcedure.mutation(async () => { diff --git a/apps/desktop/src/lib/trpc/routers/projects/projects.ts b/apps/desktop/src/lib/trpc/routers/projects/projects.ts index 169fa8cc7d4..614e0cc7441 100644 --- a/apps/desktop/src/lib/trpc/routers/projects/projects.ts +++ b/apps/desktop/src/lib/trpc/routers/projects/projects.ts @@ -171,6 +171,28 @@ function upsertProject(mainRepoPath: string, defaultBranch: string): Project { return project; } +async function ensureProjectGitHubOwner(project: Project): Promise { + if (project.githubOwner) { + return project; + } + + const githubOwner = await fetchGitHubOwner(project.mainRepoPath); + if (!githubOwner) { + return project; + } + + localDb + .update(projects) + .set({ githubOwner }) + .where(eq(projects.id, project.id)) + .run(); + + return { + ...project, + githubOwner, + }; +} + async function ensureMainWorkspace(project: Project): Promise { const existingBranchWorkspace = getBranchWorkspace(project.id); @@ -1070,7 +1092,9 @@ export const createProjectsRouter = (getWindow: () => BrowserWindow | null) => { const mainRepoPath = await getGitRoot(selectedPath); const defaultBranch = await getDefaultBranch(mainRepoPath); - const project = upsertProject(mainRepoPath, defaultBranch); + const project = await ensureProjectGitHubOwner( + upsertProject(mainRepoPath, defaultBranch), + ); await ensureMainWorkspace(project); track("project_opened", { @@ -1142,7 +1166,9 @@ export const createProjectsRouter = (getWindow: () => BrowserWindow | null) => { const defaultBranch = await getDefaultBranch(mainRepoPath); - const project = upsertProject(mainRepoPath, defaultBranch); + const project = await ensureProjectGitHubOwner( + upsertProject(mainRepoPath, defaultBranch), + ); await ensureMainWorkspace(project); track("project_opened", { @@ -1161,7 +1187,9 @@ export const createProjectsRouter = (getWindow: () => BrowserWindow | null) => { .mutation(async ({ input }) => { const { defaultBranch } = await initGitRepo(input.path); - const project = upsertProject(input.path, defaultBranch); + const project = await ensureProjectGitHubOwner( + upsertProject(input.path, defaultBranch), + ); await ensureMainWorkspace(project); track("project_opened", { @@ -1252,12 +1280,14 @@ export const createProjectsRouter = (getWindow: () => BrowserWindow | null) => { .where(eq(projects.id, existingProject.id)) .run(); - // Auto-create main workspace if it doesn't exist - await ensureMainWorkspace({ + const hydratedProject = await ensureProjectGitHubOwner({ ...existingProject, lastOpenedAt: Date.now(), }); + // Auto-create main workspace if it doesn't exist + await ensureMainWorkspace(hydratedProject); + track("project_opened", { project_id: existingProject.id, method: "clone", @@ -1266,7 +1296,7 @@ export const createProjectsRouter = (getWindow: () => BrowserWindow | null) => { return { canceled: false as const, success: true as const, - project: { ...existingProject, lastOpenedAt: Date.now() }, + project: hydratedProject, }; } catch { // Directory is missing - remove the stale project record and continue with clone @@ -1293,16 +1323,18 @@ export const createProjectsRouter = (getWindow: () => BrowserWindow | null) => { // Create new project const name = basename(clonePath); const defaultBranch = await getDefaultBranch(clonePath); - const project = localDb - .insert(projects) - .values({ - mainRepoPath: clonePath, - name, - color: getDefaultProjectColor(), - defaultBranch, - }) - .returning() - .get(); + const project = await ensureProjectGitHubOwner( + localDb + .insert(projects) + .values({ + mainRepoPath: clonePath, + name, + color: getDefaultProjectColor(), + defaultBranch, + }) + .returning() + .get(), + ); // Auto-create main workspace if it doesn't exist await ensureMainWorkspace(project); @@ -1365,7 +1397,9 @@ export const createProjectsRouter = (getWindow: () => BrowserWindow | null) => { await rm(repoPath, { recursive: true, force: true }); throw gitErr; } - const project = upsertProject(repoPath, defaultBranch); + const project = await ensureProjectGitHubOwner( + upsertProject(repoPath, defaultBranch), + ); await ensureMainWorkspace(project); track("project_opened", { diff --git a/apps/desktop/src/lib/trpc/routers/projects/utils/github.ts b/apps/desktop/src/lib/trpc/routers/projects/utils/github.ts index 506edc50ec0..253306ae0aa 100644 --- a/apps/desktop/src/lib/trpc/routers/projects/utils/github.ts +++ b/apps/desktop/src/lib/trpc/routers/projects/utils/github.ts @@ -1,5 +1,24 @@ +import { execGitWithShellPath } from "../../workspaces/utils/git-client"; import { execWithShellEnv } from "../../workspaces/utils/shell-env"; +function parseGitHubOwnerFromRemoteUrl(remoteUrl: string): string | null { + const trimmed = remoteUrl.trim(); + const patterns = [ + /^git@github\.com:(?[^/]+)\/[^/]+?(?:\.git)?$/, + /^ssh:\/\/git@github\.com\/(?[^/]+)\/[^/]+?(?:\.git)?$/, + /^https:\/\/github\.com\/(?[^/]+)\/[^/]+?(?:\.git)?\/?$/, + ]; + + for (const pattern of patterns) { + const match = pattern.exec(trimmed); + if (match?.groups?.owner) { + return match.groups.owner; + } + } + + return null; +} + /** * Fetches the GitHub owner (user or org) for a repository using the `gh` CLI. * Returns null if `gh` is not installed, not authenticated, or on error. @@ -7,6 +26,21 @@ import { execWithShellEnv } from "../../workspaces/utils/shell-env"; export async function fetchGitHubOwner( repoPath: string, ): Promise { + try { + const { stdout } = await execGitWithShellPath( + ["remote", "get-url", "origin"], + { + cwd: repoPath, + }, + ); + const owner = parseGitHubOwnerFromRemoteUrl(stdout); + if (owner) { + return owner; + } + } catch { + // Fall back to gh when no origin remote exists or the remote is not GitHub. + } + try { const { stdout } = await execWithShellEnv( "gh", diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/query.ts b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/query.ts index 4fa87789c46..381d6f8bd22 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/query.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/query.ts @@ -1,5 +1,6 @@ import { projects, + type SelectProject, workspaceSections, workspaces, worktrees, @@ -9,6 +10,7 @@ import { eq, isNotNull, isNull } from "drizzle-orm"; import { localDb } from "main/lib/local-db"; import { z } from "zod"; import { publicProcedure, router } from "../../.."; +import { fetchGitHubOwner } from "../../projects/utils/github"; import { getWorkspace } from "../utils/db-helpers"; import { getProjectChildItems } from "../utils/project-children-order"; import { loadSetupConfig } from "../utils/setup"; @@ -36,6 +38,30 @@ function getWorkspacesInVisualOrder(): string[] { return computeVisualOrder(activeProjects, allWorkspaces, allSections); } +async function ensureProjectHasGitHubOwner( + project: SelectProject, +): Promise { + if (project.githubOwner) { + return project; + } + + const githubOwner = await fetchGitHubOwner(project.mainRepoPath); + if (!githubOwner) { + return project; + } + + localDb + .update(projects) + .set({ githubOwner }) + .where(eq(projects.id, project.id)) + .run(); + + return { + ...project, + githubOwner, + }; +} + export const createQueryProcedures = () => { return router({ get: publicProcedure @@ -54,6 +80,9 @@ export const createQueryProcedures = () => { .from(projects) .where(eq(projects.id, workspace.projectId)) .get(); + const resolvedProject = project + ? await ensureProjectHasGitHubOwner(project) + : null; const worktree = workspace.worktreeId ? localDb .select() @@ -66,13 +95,13 @@ export const createQueryProcedures = () => { ...workspace, type: workspace.type as "worktree" | "branch", worktreePath: getWorkspacePath(workspace) ?? "", - project: project + project: resolvedProject ? { - id: project.id, - name: project.name, - mainRepoPath: project.mainRepoPath, - githubOwner: project.githubOwner ?? null, - defaultBranch: project.defaultBranch ?? null, + id: resolvedProject.id, + name: resolvedProject.name, + mainRepoPath: resolvedProject.mainRepoPath, + githubOwner: resolvedProject.githubOwner ?? null, + defaultBranch: resolvedProject.defaultBranch ?? null, } : null, worktree: worktree @@ -95,7 +124,7 @@ export const createQueryProcedures = () => { .sort((a, b) => a.tabOrder - b.tabOrder); }), - getAllGrouped: publicProcedure.query(() => { + getAllGrouped: publicProcedure.query(async () => { type WorkspaceItem = { id: string; projectId: string; @@ -135,6 +164,9 @@ export const createQueryProcedures = () => { .from(projects) .where(isNotNull(projects.tabOrder)) .all(); + const resolvedProjects = await Promise.all( + activeProjects.map((project) => ensureProjectHasGitHubOwner(project)), + ); const allWorktrees = localDb.select().from(worktrees).all(); const worktreePathMap: WorktreePathMap = new Map( @@ -165,7 +197,7 @@ export const createQueryProcedures = () => { } >(); - for (const project of activeProjects) { + for (const project of resolvedProjects) { const projectSections = allSections .filter((s) => s.projectId === project.id) .sort((a, b) => a.tabOrder - b.tabOrder) diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/github.test.ts b/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/github.test.ts index a8794a67daa..a7aaa0804f9 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/github.test.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/github.test.ts @@ -6,6 +6,10 @@ import { parseReviewThreadCommentsResponse, } from "./comments"; import { resolveRemoteBranchNameForGitHubStatus } from "./github"; +import { + canAttachPullRequestToWorkspace, + resolveOpenPullRequestPushTarget, +} from "./pr-attachment"; import { branchMatchesPR, getPRHeadBranchCandidates, @@ -74,6 +78,95 @@ describe("getPullRequestRepoArgs", () => { }); }); +describe("pull request attachment", () => { + test("attaches same-repo open PRs using the fallback remote", () => { + expect( + resolveOpenPullRequestPushTarget({ + pr: { + headRefName: "feature/my-thing", + isCrossRepository: false, + state: "open", + }, + remotes: [ + { + name: "origin", + fetchUrl: "git@github.com:superset-sh/superset.git", + }, + ], + fallbackRemote: "origin", + }), + ).toEqual({ + remote: "origin", + targetBranch: "feature/my-thing", + }); + }); + + test("does not attach cross-repo open PRs when the fork remote is missing", () => { + expect( + canAttachPullRequestToWorkspace({ + pr: { + headRefName: "feature/my-thing", + headRepositoryOwner: "forkowner", + headRepositoryName: "superset", + isCrossRepository: true, + state: "open", + }, + remotes: [ + { + name: "origin", + fetchUrl: "git@github.com:superset-sh/superset.git", + }, + ], + fallbackRemote: "origin", + }), + ).toBe(false); + }); + + test("attaches cross-repo open PRs when the fork remote exists", () => { + expect( + resolveOpenPullRequestPushTarget({ + pr: { + headRefName: "feature/my-thing", + headRepositoryOwner: "forkowner", + headRepositoryName: "superset", + isCrossRepository: true, + state: "draft", + }, + remotes: [ + { + name: "origin", + fetchUrl: "git@github.com:superset-sh/superset.git", + }, + { + name: "forkowner", + fetchUrl: "git@github.com:forkowner/superset.git", + }, + ], + fallbackRemote: "origin", + }), + ).toEqual({ + remote: "forkowner", + targetBranch: "feature/my-thing", + }); + }); + + test("keeps historical PRs attached even without a fork remote", () => { + expect( + canAttachPullRequestToWorkspace({ + pr: { + headRefName: "feature/my-thing", + headRepositoryOwner: "forkowner", + headRepositoryName: "superset", + isCrossRepository: true, + state: "merged", + }, + remotes: [], + fallbackRemote: "origin", + }), + ).toBe(true); + }); +}); + describe("shouldRefreshCachedRepoContext", () => { test("returns false when no cached repo context exists", () => { expect( diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/github.ts b/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/github.ts index 7c1bd9782bb..c95d0e6c840 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/github.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/github.ts @@ -4,7 +4,7 @@ import { getCurrentBranch, isUnbornHeadError, } from "../git"; -import { execGitWithShellPath } from "../git-client"; +import { execGitWithShellPath, getSimpleGitWithShellPath } from "../git-client"; import { execWithShellEnv } from "../shell-env"; import { parseUpstreamRef } from "../upstream-ref"; import { @@ -15,6 +15,10 @@ import { readCachedPullRequestComments, } from "./cache"; import { fetchPullRequestComments, resolveReviewThread } from "./comments"; +import { + canAttachPullRequestToWorkspace, + type GitRemoteInfo, +} from "./pr-attachment"; import { getPRForBranch } from "./pr-resolution"; import { extractNwoFromUrl, getRepoContext } from "./repo-context"; import { @@ -49,30 +53,34 @@ function getPullRequestCommentsRepoNameWithOwner( return extractNwoFromUrl(targetUrl); } -async function resolvePullRequestCommentsTarget( +async function getGitRemoteInfos( worktreePath: string, -): Promise { - const repoContext = await getRepoContext(worktreePath); - if (!repoContext) { - return null; - } +): Promise { + const git = await getSimpleGitWithShellPath(worktreePath); + const remotes = await git.getRemotes(true); + return remotes.map((remote) => ({ + name: remote.name, + fetchUrl: remote.refs.fetch, + pushUrl: remote.refs.push, + })); +} - const branchName = await getCurrentBranch(worktreePath); - if (!branchName) { - return null; - } - const shaResult = await execGitWithShellPath(["rev-parse", "HEAD"], { - cwd: worktreePath, - }).catch((error) => { - if (isUnbornHeadError(error)) { - return { stdout: "", stderr: "" }; - } - throw error; - }); - const headSha = shaResult.stdout.trim() || undefined; +async function resolveAttachedPullRequest({ + worktreePath, + localBranch, + repoContext, + headSha, + fallbackRemote, +}: { + worktreePath: string; + localBranch: string; + repoContext: RepoContext; + headSha?: string; + fallbackRemote: string; +}): Promise { const prInfo = await getPRForBranch( worktreePath, - branchName, + localBranch, repoContext, headSha, ); @@ -80,10 +88,32 @@ async function resolvePullRequestCommentsTarget( return null; } + const remotes = await getGitRemoteInfos(worktreePath); + return canAttachPullRequestToWorkspace({ + pr: prInfo, + remotes, + fallbackRemote, + }) + ? prInfo + : null; +} + +async function resolvePullRequestCommentsTarget( + worktreePath: string, +): Promise { + const githubStatus = await fetchGitHubPRStatus(worktreePath); + if (!githubStatus?.pr) { + return null; + } + return { - prNumber: prInfo.number, - repoContext, - prUrl: prInfo.url, + prNumber: githubStatus.pr.number, + repoContext: { + repoUrl: githubStatus.repoUrl, + upstreamUrl: githubStatus.upstreamUrl ?? githubStatus.repoUrl, + isFork: githubStatus.isFork ?? false, + }, + prUrl: githubStatus.pr.url, }; } @@ -135,7 +165,13 @@ async function refreshGitHubPRStatus( }); const [prInfo, previewUrl] = await Promise.all([ - getPRForBranch(worktreePath, branchName, repoContext, headSha), + resolveAttachedPullRequest({ + worktreePath, + localBranch: branchName, + repoContext, + headSha, + fallbackRemote: trackingRemote, + }), fetchPreviewDeploymentUrl( worktreePath, headSha, diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/pr-attachment.ts b/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/pr-attachment.ts new file mode 100644 index 00000000000..11da86fa025 --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/pr-attachment.ts @@ -0,0 +1,152 @@ +import type { GitHubStatus } from "@superset/local-db"; +import { normalizeGitHubUrl } from "./repo-context"; + +type PullRequest = NonNullable; + +export interface GitRemoteInfo { + name: string; + fetchUrl?: string; + pushUrl?: string; +} + +export interface GitTrackingRefInfo { + remoteName: string; + branchName: string; +} + +export interface PullRequestPushTargetInfo { + remote: string; + targetBranch: string; +} + +export function isOpenPullRequestState(state: PullRequest["state"]): boolean { + return state === "open" || state === "draft"; +} + +export function getPullRequestHeadRepoUrl( + pr: Pick< + PullRequest, + "headRepositoryOwner" | "headRepositoryName" | "isCrossRepository" + >, +): string | null { + if ( + !pr.isCrossRepository || + !pr.headRepositoryOwner || + !pr.headRepositoryName + ) { + return null; + } + + return `https://github.com/${pr.headRepositoryOwner}/${pr.headRepositoryName}`; +} + +export function resolveRemoteNameForPullRequestHead({ + remotes, + pr, + fallbackRemote, +}: { + remotes: GitRemoteInfo[]; + pr: Pick< + PullRequest, + "headRepositoryOwner" | "headRepositoryName" | "isCrossRepository" + >; + fallbackRemote: string; +}): string | null { + if (!pr.isCrossRepository) { + return fallbackRemote; + } + + const headRepoUrl = getPullRequestHeadRepoUrl(pr); + if (!headRepoUrl) { + return null; + } + + const normalizedHeadRepoUrl = normalizeGitHubUrl(headRepoUrl); + if (!normalizedHeadRepoUrl) { + return null; + } + + for (const remote of remotes) { + const fetchUrl = remote.fetchUrl + ? normalizeGitHubUrl(remote.fetchUrl) + : null; + const pushUrl = remote.pushUrl ? normalizeGitHubUrl(remote.pushUrl) : null; + if ( + fetchUrl === normalizedHeadRepoUrl || + pushUrl === normalizedHeadRepoUrl + ) { + return remote.name; + } + } + + return null; +} + +export function resolveOpenPullRequestPushTarget({ + pr, + remotes, + fallbackRemote, +}: { + pr: Pick< + PullRequest, + | "headRefName" + | "headRepositoryOwner" + | "headRepositoryName" + | "isCrossRepository" + | "state" + >; + remotes: GitRemoteInfo[]; + fallbackRemote: string; +}): PullRequestPushTargetInfo | null { + if (!isOpenPullRequestState(pr.state)) { + return null; + } + + const targetBranch = pr.headRefName?.trim(); + if (!targetBranch) { + return null; + } + + const remote = resolveRemoteNameForPullRequestHead({ + remotes, + pr, + fallbackRemote, + }); + if (!remote) { + return null; + } + + return { + remote, + targetBranch, + }; +} + +export function canAttachPullRequestToWorkspace({ + pr, + remotes, + fallbackRemote, +}: { + pr: Pick< + PullRequest, + | "headRefName" + | "headRepositoryOwner" + | "headRepositoryName" + | "isCrossRepository" + | "state" + >; + remotes: GitRemoteInfo[]; + fallbackRemote: string; +}): boolean { + if (!isOpenPullRequestState(pr.state)) { + return true; + } + + return ( + resolveOpenPullRequestPushTarget({ + pr, + remotes, + fallbackRemote, + }) !== null + ); +} diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 97f87619d92..16827706dab 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -16,6 +16,7 @@ import { handleAuthCallback, parseAuthDeepLink, } from "lib/trpc/routers/auth/utils/auth-functions"; +import { fetchGitHubOwner } from "lib/trpc/routers/projects/utils/github"; import { applyShellEnvToProcess } from "lib/trpc/routers/workspaces/utils/shell-env"; import { DEFAULT_CONFIRM_ON_QUIT, @@ -26,6 +27,8 @@ import { setupAgentHooks } from "./lib/agent-setup"; import { initAppState } from "./lib/app-state"; import { requestAppleEventsAccess } from "./lib/apple-events-permission"; import { setupAutoUpdater } from "./lib/auto-updater"; +import { initializeBrowserIdentityManager } from "./lib/browser/browser-identity-manager"; +import { browserSitePermissionManager } from "./lib/browser/browser-site-permission-manager"; import { resolveDevWorkspaceName } from "./lib/dev-workspace-name"; import { setWorkspaceDockIcon } from "./lib/dock-icon"; import { loadWebviewBrowserExtension } from "./lib/extensions"; @@ -115,7 +118,9 @@ function normalizeOptionalPositiveInt(value: string | null): string | null { return String(parsed); } -function resolveWorkspaceOpenRouteFromDeepLink(url: URL): string | null { +async function resolveWorkspaceOpenRouteFromDeepLink( + url: URL, +): Promise { const repoParam = url.searchParams.get("repo"); const fileParam = url.searchParams.get("file"); const branchParam = url.searchParams.get("branch")?.trim() || null; @@ -131,38 +136,64 @@ function resolveWorkspaceOpenRouteFromDeepLink(url: URL): string | null { workspaceBranch: workspaces.branch, lastOpenedAt: workspaces.lastOpenedAt, projectGithubOwner: projects.githubOwner, + projectId: projects.id, projectMainRepoPath: projects.mainRepoPath, }) .from(workspaces) .innerJoin(projects, eq(workspaces.projectId, projects.id)) .where(isNull(workspaces.deletingAt)) .orderBy(desc(workspaces.lastOpenedAt)) - .all() - .filter((row) => { - const repoName = path.basename(row.projectMainRepoPath).toLowerCase(); - if (repoName !== normalizedRepo.repo) { - return false; + .all(); + const candidatesWithOwner = await Promise.all( + candidates.map(async (row) => { + if (row.projectGithubOwner) { + return row; } - if (!normalizedRepo.owner) { - return true; + const projectGithubOwner = await fetchGitHubOwner( + row.projectMainRepoPath, + ); + if (!projectGithubOwner) { + return row; } - return ( - (row.projectGithubOwner ?? "").toLowerCase() === normalizedRepo.owner - ); - }); + localDb + .update(projects) + .set({ githubOwner: projectGithubOwner }) + .where(eq(projects.id, row.projectId)) + .run(); + + return { + ...row, + projectGithubOwner, + }; + }), + ); + const filteredCandidates = candidatesWithOwner.filter((row) => { + const repoName = path.basename(row.projectMainRepoPath).toLowerCase(); + if (repoName !== normalizedRepo.repo) { + return false; + } + + if (!normalizedRepo.owner) { + return true; + } + + return ( + (row.projectGithubOwner ?? "").toLowerCase() === normalizedRepo.owner + ); + }); - if (candidates.length === 0) { + if (filteredCandidates.length === 0) { return null; } const match = (branchParam - ? candidates.find( + ? filteredCandidates.find( (candidate) => candidate.workspaceBranch === branchParam, ) - : null) ?? candidates[0]; + : null) ?? filteredCandidates[0]; if (!match) { return null; @@ -187,7 +218,9 @@ function resolveWorkspaceOpenRouteFromDeepLink(url: URL): string | null { return `/workspace/${match.workspaceId}${search ? `?${search}` : ""}`; } -function getRendererPathFromDeepLink(urlString: string): string | null { +async function getRendererPathFromDeepLink( + urlString: string, +): Promise { let parsed: URL; try { parsed = new URL(urlString); @@ -196,7 +229,9 @@ function getRendererPathFromDeepLink(urlString: string): string | null { } if (parsed.hostname === "open") { - return resolveWorkspaceOpenRouteFromDeepLink(parsed) ?? "/workspace"; + return ( + (await resolveWorkspaceOpenRouteFromDeepLink(parsed)) ?? "/workspace" + ); } const host = parsed.hostname ? `/${parsed.hostname}` : ""; @@ -220,7 +255,7 @@ async function processDeepLink(url: string): Promise { return; } - const path = getRendererPathFromDeepLink(url); + const path = await getRendererPathFromDeepLink(url); if (!path) { console.error("[main] Failed to resolve deep link route:", url); return; @@ -436,6 +471,8 @@ if (!gotTheLock) { await app.whenReady(); registerWithMacOSNotificationCenter(); requestAppleEventsAccess(); + initializeBrowserIdentityManager(); + browserSitePermissionManager.initialize(); // Must register on both default session and the app's custom partition const iconProtocolHandler = (request: Request) => { diff --git a/apps/desktop/src/main/lib/browser/browser-identity-manager.ts b/apps/desktop/src/main/lib/browser/browser-identity-manager.ts new file mode 100644 index 00000000000..9c2d4085387 --- /dev/null +++ b/apps/desktop/src/main/lib/browser/browser-identity-manager.ts @@ -0,0 +1,76 @@ +import { session } from "electron"; + +const APP_BROWSER_PARTITION = "persist:superset"; + +function getChromeVersion(): string { + return process.versions.chrome ?? "140.0.0.0"; +} + +function getChromeMajorVersion(): string { + return getChromeVersion().split(".")[0] ?? "140"; +} + +function getChromeLikeUserAgent(userAgent: string): string { + return userAgent.replace(/\sElectron\/[^\s]+/g, "").trim(); +} + +function getClientHintPlatform(): string { + switch (process.platform) { + case "darwin": + return "macOS"; + case "win32": + return "Windows"; + default: + return "Linux"; + } +} + +function setHeader( + headers: Record, + name: string, + value: string, +): void { + const existingKey = Object.keys(headers).find( + (headerName) => headerName.toLowerCase() === name.toLowerCase(), + ); + if (existingKey) { + headers[existingKey] = value; + return; + } + + headers[name] = value; +} + +let initialized = false; + +export function initializeBrowserIdentityManager(): void { + if (initialized) { + return; + } + + initialized = true; + + const browserSession = session.fromPartition(APP_BROWSER_PARTITION); + const chromeVersion = getChromeVersion(); + const chromeMajorVersion = getChromeMajorVersion(); + const clientHintPlatform = getClientHintPlatform(); + const secChUa = `"Google Chrome";v="${chromeMajorVersion}", "Chromium";v="${chromeMajorVersion}", "Not_A Brand";v="24"`; + const secChUaFullVersionList = `"Google Chrome";v="${chromeVersion}", "Chromium";v="${chromeVersion}", "Not_A Brand";v="24.0.0.0"`; + + browserSession.webRequest.onBeforeSendHeaders((details, callback) => { + const headers = { ...details.requestHeaders }; + const originalUserAgent = + headers["User-Agent"] ?? + headers["user-agent"] ?? + `Mozilla/5.0 Chrome/${chromeVersion}`; + + setHeader(headers, "User-Agent", getChromeLikeUserAgent(originalUserAgent)); + setHeader(headers, "Sec-CH-UA", secChUa); + setHeader(headers, "Sec-CH-UA-Mobile", "?0"); + setHeader(headers, "Sec-CH-UA-Platform", `"${clientHintPlatform}"`); + setHeader(headers, "Sec-CH-UA-Full-Version", `"${chromeVersion}"`); + setHeader(headers, "Sec-CH-UA-Full-Version-List", secChUaFullVersionList); + + callback({ requestHeaders: headers }); + }); +} diff --git a/apps/desktop/src/main/lib/browser/browser-manager.ts b/apps/desktop/src/main/lib/browser/browser-manager.ts index 00a830103f8..53022cd2f9e 100644 --- a/apps/desktop/src/main/lib/browser/browser-manager.ts +++ b/apps/desktop/src/main/lib/browser/browser-manager.ts @@ -84,6 +84,16 @@ class BrowserManager extends EventEmitter { return wc; } + getPaneIdForWebContents(webContentsId: number): string | null { + for (const [paneId, registeredWebContentsId] of this.paneWebContentsIds) { + if (registeredWebContentsId === webContentsId) { + return paneId; + } + } + + return null; + } + navigate(paneId: string, url: string): void { const wc = this.getWebContents(paneId); if (!wc) throw new Error(`No webContents for pane ${paneId}`); diff --git a/apps/desktop/src/main/lib/browser/browser-site-permission-manager.ts b/apps/desktop/src/main/lib/browser/browser-site-permission-manager.ts new file mode 100644 index 00000000000..b0fe00f7f1d --- /dev/null +++ b/apps/desktop/src/main/lib/browser/browser-site-permission-manager.ts @@ -0,0 +1,291 @@ +import { EventEmitter } from "node:events"; +import { + browserSitePermissions, + type SitePermissionKind, + type SitePermissionValue, +} from "@superset/local-db"; +import { and, eq } from "drizzle-orm"; +import { session } from "electron"; +import { localDb } from "../local-db"; +import { browserManager } from "./browser-manager"; + +const APP_BROWSER_PARTITION = "persist:superset"; + +const DEFAULT_SITE_PERMISSIONS: Record< + SitePermissionKind, + SitePermissionValue +> = { + microphone: "ask", + camera: "ask", +}; + +interface SitePermissionRequestEvent { + paneId: string; + origin: string; + permissions: SitePermissionKind[]; +} + +function normalizeOrigin(value: string): string | null { + if (!value || value === "about:blank") { + return null; + } + + try { + const parsed = new URL(value); + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + return null; + } + return parsed.origin; + } catch { + return null; + } +} + +function mediaTypeToPermissionKind( + mediaType: "audio" | "video" | "unknown", +): SitePermissionKind | null { + if (mediaType === "audio") { + return "microphone"; + } + if (mediaType === "video") { + return "camera"; + } + return null; +} + +class BrowserSitePermissionManager extends EventEmitter { + private initialized = false; + private lastRequestNotificationAt = new Map(); + + initialize(): void { + if (this.initialized) { + return; + } + + this.initialized = true; + + const browserSession = session.fromPartition(APP_BROWSER_PARTITION); + + browserSession.setPermissionCheckHandler( + (webContents, permission, requestingOrigin, details) => { + if (permission !== "media") { + return false; + } + + const origin = + normalizeOrigin( + (details as { securityOrigin?: string }).securityOrigin ?? "", + ) ?? + normalizeOrigin(requestingOrigin) ?? + normalizeOrigin(webContents?.getURL() ?? ""); + + if (!origin) { + return false; + } + + const permissionKind = mediaTypeToPermissionKind( + details.mediaType ?? "unknown", + ); + if (!permissionKind) { + return false; + } + + return this.getPermission(origin, permissionKind) === "allow"; + }, + ); + + browserSession.setPermissionRequestHandler( + (webContents, permission, callback, details) => { + if (permission !== "media") { + callback(true); + return; + } + + const origin = + normalizeOrigin( + (details as { securityOrigin?: string }).securityOrigin ?? "", + ) ?? + normalizeOrigin(details.requestingUrl ?? "") ?? + normalizeOrigin(webContents.getURL()); + + if (!origin) { + callback(false); + return; + } + + const requestedPermissions = [ + ...new Set( + ( + (details as { mediaTypes?: ("audio" | "video" | "unknown")[] }) + .mediaTypes ?? [] + ) + .map((mediaType) => mediaTypeToPermissionKind(mediaType)) + .filter((value): value is SitePermissionKind => value !== null), + ), + ]; + + if (requestedPermissions.length === 0) { + callback(false); + return; + } + + const blocked = requestedPermissions.some( + (permissionKind) => + this.getPermission(origin, permissionKind) === "block", + ); + if (blocked) { + callback(false); + return; + } + + const unresolvedPermissions = requestedPermissions.filter( + (permissionKind) => + this.getPermission(origin, permissionKind) !== "allow", + ); + + if (unresolvedPermissions.length === 0) { + callback(true); + return; + } + + const paneId = browserManager.getPaneIdForWebContents(webContents.id); + if (paneId) { + this.emitPermissionRequested({ + paneId, + origin, + permissions: unresolvedPermissions, + }); + } + + callback(false); + }, + ); + } + + getPermissionsForUrl(url: string): { + origin: string; + permissions: Record; + } | null { + const origin = normalizeOrigin(url); + if (!origin) { + return null; + } + + return { + origin, + permissions: this.getPermissionsForOrigin(origin), + }; + } + + getPermissionsForOrigin( + origin: string, + ): Record { + const normalizedOrigin = normalizeOrigin(origin); + if (!normalizedOrigin) { + return { ...DEFAULT_SITE_PERMISSIONS }; + } + + const rows = localDb + .select() + .from(browserSitePermissions) + .where(eq(browserSitePermissions.origin, normalizedOrigin)) + .all(); + + const permissions = { ...DEFAULT_SITE_PERMISSIONS }; + for (const row of rows) { + permissions[row.kind] = row.value; + } + + return permissions; + } + + setPermission( + origin: string, + kind: SitePermissionKind, + value: SitePermissionValue, + ): { + origin: string; + permissions: Record; + } { + const normalizedOrigin = normalizeOrigin(origin); + if (!normalizedOrigin) { + throw new Error( + "Site settings are only available for http and https pages", + ); + } + + localDb + .insert(browserSitePermissions) + .values({ + origin: normalizedOrigin, + kind, + value, + createdAt: Date.now(), + updatedAt: Date.now(), + }) + .onConflictDoUpdate({ + target: [browserSitePermissions.origin, browserSitePermissions.kind], + set: { + value, + updatedAt: Date.now(), + }, + }) + .run(); + + return { + origin: normalizedOrigin, + permissions: this.getPermissionsForOrigin(normalizedOrigin), + }; + } + + resetPermissions(origin: string): void { + const normalizedOrigin = normalizeOrigin(origin); + if (!normalizedOrigin) { + throw new Error( + "Site settings are only available for http and https pages", + ); + } + + localDb + .delete(browserSitePermissions) + .where(eq(browserSitePermissions.origin, normalizedOrigin)) + .run(); + } + + private getPermission( + origin: string, + kind: SitePermissionKind, + ): SitePermissionValue { + const normalizedOrigin = normalizeOrigin(origin); + if (!normalizedOrigin) { + return "ask"; + } + + const row = localDb + .select() + .from(browserSitePermissions) + .where( + and( + eq(browserSitePermissions.origin, normalizedOrigin), + eq(browserSitePermissions.kind, kind), + ), + ) + .get(); + + return row?.value ?? "ask"; + } + + private emitPermissionRequested(event: SitePermissionRequestEvent): void { + const dedupeKey = `${event.paneId}:${event.origin}:${[...event.permissions].sort().join(",")}`; + const now = Date.now(); + const previous = this.lastRequestNotificationAt.get(dedupeKey) ?? 0; + if (now - previous < 1500) { + return; + } + + this.lastRequestNotificationAt.set(dedupeKey, now); + this.emit(`permission-requested:${event.paneId}`, event); + } +} + +export const browserSitePermissionManager = new BrowserSitePermissionManager(); diff --git a/apps/desktop/src/renderer/index.tsx b/apps/desktop/src/renderer/index.tsx index 3f951aa1b89..80db3d08298 100644 --- a/apps/desktop/src/renderer/index.tsx +++ b/apps/desktop/src/renderer/index.tsx @@ -38,8 +38,18 @@ const unsubscribe = router.subscribe("onResolved", (event) => { }); }); +function persistDeepLinkedWorkspace(path: string): void { + const match = /^\/workspace\/([^/?#]+)/.exec(path); + if (!match?.[1]) { + return; + } + + localStorage.setItem("lastViewedWorkspaceId", decodeURIComponent(match[1])); +} + const handleDeepLink = (path: string) => { console.log("[deep-link] Navigating to:", path); + persistDeepLinkedWorkspace(path); router.navigate({ to: path }); }; const ipcRenderer = window.ipcRenderer as typeof window.ipcRenderer | undefined; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/workspace/$workspaceId/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/workspace/$workspaceId/page.tsx index c2a59b325b4..b77293b7264 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/workspace/$workspaceId/page.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/workspace/$workspaceId/page.tsx @@ -161,6 +161,7 @@ export function WorkspacePage({ // Handle search-param-driven tab/pane activation (e.g. from notification clicks) useEffect(() => { + if (!isActive) return; if (!searchTabId) return; const state = useTabsStore.getState(); @@ -181,9 +182,10 @@ export function WorkspacePage({ search: {}, replace: true, }); - }, [searchTabId, searchPaneId, workspaceId, navigate]); + }, [isActive, searchTabId, searchPaneId, workspaceId, navigate]); useEffect(() => { + if (!isActive) return; if (!searchFile || !workspace?.worktreePath) return; const filePath = toAbsoluteWorkspacePath( @@ -224,6 +226,7 @@ export function WorkspacePage({ }); }, [ addFileViewerPane, + isActive, navigate, searchColumn, searchFile, @@ -839,6 +842,7 @@ export function WorkspacePage({ ) : ( )} + {isItemVisible(SETTING_ITEM_ID.PERMISSIONS_CAMERA, visibleItems) && ( + requestCamera.mutate()} + /> + )} + {isItemVisible( SETTING_ITEM_ID.PERMISSIONS_APPLE_EVENTS, visibleItems, diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/utils/settings-search/settings-search.ts b/apps/desktop/src/renderer/routes/_authenticated/settings/utils/settings-search/settings-search.ts index 7d26deccad2..904845ff5b2 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/utils/settings-search/settings-search.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/utils/settings-search/settings-search.ts @@ -71,6 +71,7 @@ export const SETTING_ITEM_ID = { PERMISSIONS_FULL_DISK_ACCESS: "permissions-full-disk-access", PERMISSIONS_ACCESSIBILITY: "permissions-accessibility", PERMISSIONS_MICROPHONE: "permissions-microphone", + PERMISSIONS_CAMERA: "permissions-camera", PERMISSIONS_APPLE_EVENTS: "permissions-apple-events", PERMISSIONS_LOCAL_NETWORK: "permissions-local-network", } as const; @@ -1040,6 +1041,24 @@ export const SETTINGS_ITEMS: SettingsItem[] = [ "privacy", ], }, + { + id: SETTING_ITEM_ID.PERMISSIONS_CAMERA, + section: "permissions", + title: "Camera", + description: + "Use video input in websites and tools running inside Superset", + keywords: [ + "permissions", + "camera", + "webcam", + "video", + "recording", + "capture", + "privacy", + "browser", + "website", + ], + }, { id: SETTING_ITEM_ID.PERMISSIONS_APPLE_EVENTS, section: "permissions", diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/BrowserPane.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/BrowserPane.tsx index a64a871292b..87cd9afa68e 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/BrowserPane.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/BrowserPane.tsx @@ -1,6 +1,6 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; import { GlobeIcon } from "lucide-react"; -import { useCallback, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { LuMinus, LuPlus } from "react-icons/lu"; import { TbDeviceDesktop } from "react-icons/tb"; import type { MosaicBranch } from "react-mosaic-component"; @@ -63,6 +63,9 @@ export function BrowserPane({ const toggleBookmark = useBrowserBookmarksStore( (state) => state.toggleBookmark, ); + const syncBookmarkFaviconByUrl = useBrowserBookmarksStore( + (state) => state.syncBookmarkFaviconByUrl, + ); const { mutate: openDevTools } = electronTrpc.browser.openDevTools.useMutation(); const { mutate: setZoomLevel } = @@ -117,6 +120,22 @@ export function BrowserPane({ }, [openDevTools, paneId]); const [isEditingUrl, setIsEditingUrl] = useState(false); + + useEffect(() => { + if (!currentBookmark || !currentFaviconUrl) { + return; + } + if (currentBookmark.faviconUrl === currentFaviconUrl) { + return; + } + syncBookmarkFaviconByUrl(currentUrl, currentFaviconUrl); + }, [ + currentBookmark, + currentFaviconUrl, + currentUrl, + syncBookmarkFaviconByUrl, + ]); + const handleToggleBookmark = useCallback(() => { if (isBlankPage) return; toggleBookmark({ @@ -139,6 +158,7 @@ export function BrowserPane({ renderToolbar={(handlers) => (
), diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/components/BookmarkBar/components/BookmarkBarItem/BookmarkBarItem.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/components/BookmarkBar/components/BookmarkBarItem/BookmarkBarItem.tsx index ad74158765d..bd9d3cf039a 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/components/BookmarkBar/components/BookmarkBarItem/BookmarkBarItem.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/components/BookmarkBar/components/BookmarkBarItem/BookmarkBarItem.tsx @@ -59,35 +59,41 @@ function BookmarkButton({ attributes, listeners, }: BookmarkButtonProps) { + const button = ( + + ); + + if (compact) { + return button; + } + return ( - - - + {button} {bookmark.url} @@ -209,6 +215,10 @@ export function BookmarkBarItem({ }; }, []); + useEffect(() => { + setFaviconFailed(false); + }, []); + const scheduleEditDialogOpen = () => { if (pendingOpenTimerRef.current !== null) { clearTimeout(pendingOpenTimerRef.current); diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/components/BookmarkBar/components/BookmarkFolderItem/BookmarkFolderItem.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/components/BookmarkBar/components/BookmarkFolderItem/BookmarkFolderItem.tsx index 5c2e358ea9b..fa55d141cd6 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/components/BookmarkBar/components/BookmarkFolderItem/BookmarkFolderItem.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/components/BookmarkBar/components/BookmarkFolderItem/BookmarkFolderItem.tsx @@ -21,13 +21,15 @@ import { import { DropdownMenu, DropdownMenuContent, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, DropdownMenuTrigger, } from "@superset/ui/dropdown-menu"; import { toast } from "@superset/ui/sonner"; -import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; import { cn } from "@superset/ui/utils"; import { GripVerticalIcon } from "lucide-react"; -import { type ReactNode, useEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { getBrowserBookmarkFolderIcon } from "renderer/stores/browser-bookmark-folder-icons"; import { type BrowserBookmarkFolder, @@ -42,7 +44,6 @@ import { BookmarkBarItem } from "../BookmarkBarItem"; interface BookmarkFolderItemProps { folder: BrowserBookmarkFolder; - isActive: boolean; currentUrl: string; onNavigate: (url: string) => void; } @@ -53,20 +54,21 @@ interface FolderTreeSectionProps { onNavigate: (url: string) => void; currentUrl: string; onReorder: (folderId: string, activeId: string, overId: string) => void; - depth?: number; } -interface SortableFolderTreeNodeProps { +interface SortableFolderMenuNodeProps { folder: BrowserBookmarkFolder; - depth: number; - children: ReactNode; + onNavigate: (url: string) => void; + currentUrl: string; + onReorder: (folderId: string, activeId: string, overId: string) => void; } -function SortableFolderTreeNode({ +function SortableFolderMenuNode({ folder, - depth, - children, -}: SortableFolderTreeNodeProps) { + onNavigate, + currentUrl, + onReorder, +}: SortableFolderMenuNodeProps) { const FolderIcon = getBrowserBookmarkFolderIcon(folder.iconKey); const { attributes, @@ -93,28 +95,48 @@ function SortableFolderTreeNode({
-
- - {folder.title} - + + - - -
- {children} + {folder.children.length > 0 ? ( + + ) : ( +
+ Folder is empty. +
+ )} + +
); } @@ -125,7 +147,6 @@ function FolderTreeSection({ onNavigate, currentUrl, onReorder, - depth = 0, }: FolderTreeSectionProps) { const dragLockId = `bookmark-folder-dnd-${folderId}`; const sensors = useSensors( @@ -180,18 +201,13 @@ function FolderTreeSection({ } return ( - - {node.children.length > 0 ? ( - - ) : null} - + ); })}
@@ -202,7 +218,6 @@ function FolderTreeSection({ export function BookmarkFolderItem({ folder, - isActive, currentUrl, onNavigate, }: BookmarkFolderItemProps) { @@ -266,6 +281,11 @@ export function BookmarkFolderItem({ }, 0); }; + const handleNavigateFromFolder = (url: string) => { + setIsMenuOpen(false); + onNavigate(url); + }; + return ( <> @@ -280,31 +300,20 @@ export function BookmarkFolderItem({ className={cn( "flex h-7 min-w-0 max-w-56 items-center rounded-md border transition-colors", "border-transparent bg-transparent text-muted-foreground/75 hover:bg-accent/70 hover:text-foreground", - isActive && - "border-border bg-accent text-foreground shadow-sm", )} > - - - - - - - - {folder.title} - - + + + + + + + {hasPendingRequest ? "Site requested access" : "Site Settings"} + + + + + {settingsUnavailable || !sitePermissions ? ( + + Site settings are only available for http and https pages. + + ) : ( + <> + + {sitePermissions.origin} + + {hasPendingRequest && pendingRequest ? ( + + Requested: {formatPermissionList(pendingRequest.permissions)} + + ) : null} + + + + Microphone + + + handlePermissionChange("microphone", value) + } + > + {(["ask", "allow", "block"] as const).map((value) => ( + + {VALUE_LABELS[value]} + + ))} + + + + Camera + + handlePermissionChange("camera", value)} + > + {(["ask", "allow", "block"] as const).map((value) => ( + + {VALUE_LABELS[value]} + + ))} + + + + + Reset Site Settings + + + )} + + + ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/components/BrowserToolbar/components/SitePermissionsMenu/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/components/BrowserToolbar/components/SitePermissionsMenu/index.ts new file mode 100644 index 00000000000..252fa8ea3e2 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/components/BrowserToolbar/components/SitePermissionsMenu/index.ts @@ -0,0 +1 @@ +export { SitePermissionsMenu } from "./SitePermissionsMenu"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/hooks/usePersistentWebview/usePersistentWebview.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/hooks/usePersistentWebview/usePersistentWebview.ts index 7a898676f16..9f2c9575304 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/hooks/usePersistentWebview/usePersistentWebview.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/hooks/usePersistentWebview/usePersistentWebview.ts @@ -43,6 +43,14 @@ import { let hiddenContainer: HTMLDivElement | null = null; const webviewInteractionLocks = new Set(); +function getChromeLikeUserAgent(): string { + // Electron's default UA typically appends `Electron/`, which some + // sites treat as an unsupported browser even though the engine is Chromium. + // Strip that token so embedded pages see a standard Chrome-style UA. + const defaultUserAgent = window.navigator.userAgent; + return defaultUserAgent.replace(/\sElectron\/[^\s]+/g, "").trim(); +} + function getHiddenContainer(): HTMLDivElement { if (!hiddenContainer) { hiddenContainer = document.createElement("div"); @@ -218,8 +226,10 @@ export function usePersistentWebview({ let wrapper = getPersistentWrapper(paneId); let webview = getPersistentWebview(paneId); + const chromeLikeUserAgent = getChromeLikeUserAgent(); if (wrapper && webview) { + webview.setAttribute("useragent", chromeLikeUserAgent); // Reclaim: move the wrapper (with webview inside) into React's container. // The webview's parentNode stays as `wrapper` — no reparent, no reload. container.appendChild(wrapper); @@ -236,6 +246,7 @@ export function usePersistentWebview({ clearPersistentWebviewDomReady(paneId); webview.setAttribute("partition", "persist:superset"); webview.setAttribute("allowpopups", ""); + webview.setAttribute("useragent", chromeLikeUserAgent); webview.style.display = "flex"; webview.style.flex = "1"; webview.style.width = "100%"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/index.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/index.tsx index f8a67b32f0a..86e9ff3f265 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/index.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/index.tsx @@ -223,11 +223,11 @@ function SortableTabButton({ ); } -export function RightSidebar() { +export function RightSidebar({ isActive = true }: { isActive?: boolean }) { const workspaceId = useWorkspaceId(); const { data: workspace } = electronTrpc.workspaces.get.useQuery( { id: workspaceId ?? "" }, - { enabled: !!workspaceId }, + { enabled: Boolean(workspaceId) && isActive }, ); const worktreePath = workspace?.worktreePath; const currentMode = useSidebarStore((s) => s.currentMode); @@ -253,20 +253,27 @@ export function RightSidebar() { electronTrpc.languageServices.getWorkspaceDiagnostics.useQuery( { workspaceId: workspaceId ?? "" }, { - enabled: Boolean(workspaceId), + enabled: Boolean(workspaceId) && isActive, staleTime: Infinity, }, ); - const { data: dockerComposeFiles } = - electronTrpc.docker.getComposeFiles.useQuery( - { workspaceId: workspaceId ?? "" }, - { - enabled: Boolean(workspaceId), - staleTime: 10000, - }, - ); + const dockerComposeFilesQuery = electronTrpc.docker.getComposeFiles.useQuery( + { workspaceId: workspaceId ?? "" }, + { + enabled: Boolean(workspaceId) && isActive, + staleTime: 10000, + }, + ); const hasProblemErrors = (workspaceDiagnostics?.summary.errorCount ?? 0) > 0; - const showDockerTab = (dockerComposeFiles?.composeFiles.length ?? 0) > 0; + const dockerComposeFiles = dockerComposeFilesQuery.data; + const isResolvingDockerVisibility = + Boolean(workspaceId) && + isActive && + rightSidebarTab === RightSidebarTab.Docker && + dockerComposeFilesQuery.status === "pending"; + const showDockerTab = isResolvingDockerVisibility + ? true + : (dockerComposeFiles?.composeFiles.length ?? 0) > 0; const tabSensors = useSensors( useSensor(MouseSensor, { activationConstraint: { distance: 8 }, @@ -295,6 +302,14 @@ export function RightSidebar() { }, [hasProblemErrors, rightSidebarTabOrder, showChangesTab, showDockerTab]); useEffect(() => { + if (!isActive) { + return; + } + + if (isResolvingDockerVisibility) { + return; + } + if (sidebarTabs.some((tab) => tab.id === rightSidebarTab)) { return; } @@ -303,7 +318,19 @@ export function RightSidebar() { if (fallbackTabId) { setRightSidebarTab(fallbackTabId); } - }, [rightSidebarTab, setRightSidebarTab, sidebarTabs]); + }, [ + isActive, + isResolvingDockerVisibility, + rightSidebarTab, + setRightSidebarTab, + sidebarTabs, + ]); + const handleSelectSidebarTab = useCallback( + (tabId: RightSidebarTab) => { + setRightSidebarTab(tabId); + }, + [setRightSidebarTab], + ); const handleTabDragEnd = useCallback( ({ active, over }: DragEndEvent) => { if (!over || active.id === over.id) { @@ -321,7 +348,7 @@ export function RightSidebar() { electronTrpc.languageServices.subscribeDiagnostics.useSubscription( { workspaceId: workspaceId ?? "" }, { - enabled: Boolean(workspaceId), + enabled: Boolean(workspaceId) && isActive, onData: () => { if (!workspaceId) { return; @@ -532,7 +559,7 @@ export function RightSidebar() { key={tab.id} tab={tab} isActive={rightSidebarTab === tab.id} - onClick={() => setRightSidebarTab(tab.id)} + onClick={() => handleSelectSidebarTab(tab.id)} compact={compactTabs} /> ))} @@ -564,7 +591,7 @@ export function RightSidebar() { return ( setRightSidebarTab(tab.id)} + onClick={() => handleSelectSidebarTab(tab.id)} className="gap-2" > diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceLayout/WorkspaceLayout.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceLayout/WorkspaceLayout.tsx index 4defe4f196e..7da98babf48 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceLayout/WorkspaceLayout.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceLayout/WorkspaceLayout.tsx @@ -15,6 +15,7 @@ import { RightSidebar } from "../RightSidebar"; interface WorkspaceLayoutProps { workspaceId: string; + isActive?: boolean; defaultExternalApp?: ExternalApp | null; onOpenInApp: () => void; onOpenQuickOpen: () => void; @@ -22,6 +23,7 @@ interface WorkspaceLayoutProps { export function WorkspaceLayout({ workspaceId, + isActive = true, defaultExternalApp, onOpenInApp, onOpenQuickOpen, @@ -62,7 +64,7 @@ export function WorkspaceLayout({ className={isExpanded ? "border-l-0" : undefined} onDoubleClickHandle={() => setSidebarWidth(DEFAULT_SIDEBAR_WIDTH)} > - + )} diff --git a/apps/desktop/src/renderer/stores/browser-bookmarks-html.ts b/apps/desktop/src/renderer/stores/browser-bookmarks-html.ts index 75497164147..cb2bf6e809b 100644 --- a/apps/desktop/src/renderer/stores/browser-bookmarks-html.ts +++ b/apps/desktop/src/renderer/stores/browser-bookmarks-html.ts @@ -1,5 +1,6 @@ import type { BrowserBookmark, + BrowserBookmarkFolder, BrowserBookmarkTreeNode, } from "./browser-bookmarks"; import { isBrowserBookmark, normalizeBookmarkUrl } from "./browser-bookmarks"; @@ -62,36 +63,115 @@ function parseBookmarkAnchor( }; } -function parseBookmarkList(list: Element | null): BrowserBookmarkTreeNode[] { - if (!list) return []; +function parseElementFragment( + parser: DOMParser, + html: string, + selector: string, +): T | null { + const document = parser.parseFromString(html, "text/html"); + const element = document.body.firstElementChild; + if (!element?.matches(selector)) { + return null; + } + return element as T; +} + +function parseFolderHeading( + parser: DOMParser, + html: string, +): BrowserBookmarkFolder | null { + const heading = parseElementFragment( + parser, + html, + "h1, h2, h3, h4, h5, h6", + ); + if (!heading) { + return null; + } + + return { + id: crypto.randomUUID(), + type: "folder", + title: heading.textContent?.trim() || "Untitled Folder", + createdAt: parseTimestamp(heading.getAttribute("add_date")), + children: [], + }; +} +function parseBookmarkListFromHtml(html: string): BrowserBookmarkTreeNode[] { + const parser = new DOMParser(); const nodes: BrowserBookmarkTreeNode[] = []; - for (const child of Array.from(list.children)) { - if (child.tagName !== "DT") continue; - - const heading = Array.from(child.children).find((element) => - /^H[1-6]$/i.test(element.tagName), - ); - if (heading) { - const nestedList = - child.nextElementSibling?.tagName === "DL" - ? child.nextElementSibling - : null; - nodes.push({ - id: crypto.randomUUID(), - type: "folder", - title: heading.textContent?.trim() || "Untitled Folder", - createdAt: parseTimestamp(heading.getAttribute("add_date")), - children: parseBookmarkList(nestedList), - }); + const listStack: BrowserBookmarkTreeNode[][] = []; + let pendingFolder: BrowserBookmarkFolder | null = null; + + // Netscape bookmark exports commonly omit closing tags, so we parse the + // raw token stream instead of relying on the browser's repaired DOM tree shape. + const tokenPattern = + /]*>[\s\S]*?<\/a\s*>|]*>[\s\S]*?<\/h[1-6]\s*>|<\/?dl\b[^>]*>|<\/?dt\b[^>]*>/gi; + + for (const match of html.matchAll(tokenPattern)) { + const token = match[0]; + if (!token) continue; + + if (/^ 0) { + listStack.pop(); + } + continue; + } + + if (listStack.length === 0) { + continue; + } + + if (/^( + parser, + token, + "a", + ); + const bookmark = anchor ? parseBookmarkAnchor(anchor) : null; + if (bookmark) { + const currentList = listStack[listStack.length - 1]; + currentList?.push(bookmark); + } + pendingFolder = null; } } @@ -116,10 +196,5 @@ export function exportBrowserBookmarksToHtml( export function importBrowserBookmarksFromHtml( html: string, ): BrowserBookmarkTreeNode[] { - const parser = new DOMParser(); - const document = parser.parseFromString(html, "text/html"); - const rootList = - document.querySelector("body > dl") ?? document.querySelector("dl"); - - return parseBookmarkList(rootList); + return parseBookmarkListFromHtml(html); } diff --git a/apps/desktop/src/renderer/stores/browser-bookmarks.ts b/apps/desktop/src/renderer/stores/browser-bookmarks.ts index 2153e2d08b3..a319c16cf80 100644 --- a/apps/desktop/src/renderer/stores/browser-bookmarks.ts +++ b/apps/desktop/src/renderer/stores/browser-bookmarks.ts @@ -65,6 +65,7 @@ interface BrowserBookmarksState { removeNode: (nodeId: string) => void; moveNode: (activeId: string, overId: string) => void; toggleBookmark: (bookmark: BrowserBookmarkInput) => boolean; + syncBookmarkFaviconByUrl: (url: string, faviconUrl: string) => boolean; importBookmarks: (nodes: BrowserBookmarkTreeNode[]) => { bookmarksAdded: number; foldersAdded: number; @@ -332,6 +333,49 @@ function reorderFolderChildrenInTree( return { nodes: nextNodes, reordered }; } +function syncBookmarkFaviconInTree( + nodes: BrowserBookmarkTreeNode[], + url: string, + faviconUrl: string, +): { nodes: BrowserBookmarkTreeNode[]; updated: boolean } { + const normalizedUrl = normalizeBookmarkUrl(url); + let updated = false; + + const nextNodes = nodes.map((node) => { + if (isBrowserBookmark(node)) { + if (normalizeBookmarkUrl(node.url) !== normalizedUrl) { + return node; + } + if (node.faviconUrl === faviconUrl) { + return node; + } + + updated = true; + return { + ...node, + faviconUrl, + }; + } + + const childResult = syncBookmarkFaviconInTree( + node.children, + normalizedUrl, + faviconUrl, + ); + if (!childResult.updated) { + return node; + } + + updated = true; + return { + ...node, + children: childResult.nodes, + }; + }); + + return { nodes: nextNodes, updated }; +} + function sanitizeLegacyNodes(value: unknown): BrowserBookmarkTreeNode[] { if (!Array.isArray(value)) return []; @@ -685,6 +729,26 @@ export const useBrowserBookmarksStore = create()( return get().addBookmark(bookmark) !== null; }, + syncBookmarkFaviconByUrl: (url, faviconUrl) => { + const normalizedUrl = normalizeBookmarkUrl(url); + if (!normalizedUrl || normalizedUrl === "about:blank") { + return false; + } + + let didUpdate = false; + set((state) => { + const result = syncBookmarkFaviconInTree( + state.bookmarks, + normalizedUrl, + faviconUrl, + ); + didUpdate = result.updated; + return result.updated ? { bookmarks: result.nodes } : state; + }); + + return didUpdate; + }, + importBookmarks: (nodes) => { const result = cloneImportedNodes(nodes); set((state) => ({ diff --git a/apps/desktop/src/renderer/stores/sidebar-state.ts b/apps/desktop/src/renderer/stores/sidebar-state.ts index 884b68c909f..6482ed3a654 100644 --- a/apps/desktop/src/renderer/stores/sidebar-state.ts +++ b/apps/desktop/src/renderer/stores/sidebar-state.ts @@ -39,6 +39,47 @@ function isRightSidebarTab(value: unknown): value is RightSidebarTab { ); } +function sanitizeSidebarState( + state: Partial | undefined, +): Partial { + if (!state) { + return {}; + } + + const sidebarWidth = + typeof state.sidebarWidth === "number" && state.sidebarWidth > 0 + ? Math.max( + MIN_SIDEBAR_WIDTH, + Math.min(MAX_SIDEBAR_WIDTH, state.sidebarWidth), + ) + : state.sidebarWidth === 0 + ? 0 + : DEFAULT_SIDEBAR_WIDTH; + const lastOpenSidebarWidth = + typeof state.lastOpenSidebarWidth === "number" && + state.lastOpenSidebarWidth > 0 + ? Math.max( + MIN_SIDEBAR_WIDTH, + Math.min(MAX_SIDEBAR_WIDTH, state.lastOpenSidebarWidth), + ) + : sidebarWidth > 0 + ? sidebarWidth + : DEFAULT_SIDEBAR_WIDTH; + + return { + ...state, + sidebarWidth, + lastOpenSidebarWidth, + isResizing: false, + rightSidebarTab: isRightSidebarTab(state.rightSidebarTab) + ? state.rightSidebarTab + : RightSidebarTab.Changes, + rightSidebarTabOrder: normalizeRightSidebarTabOrder( + state.rightSidebarTabOrder, + ), + }; +} + export function normalizeRightSidebarTabOrder( order: readonly RightSidebarTab[] | undefined, ): RightSidebarTab[] { @@ -215,19 +256,23 @@ export const useSidebarStore = create()( name: "sidebar-store", migrate: (persistedState: unknown, _version: number) => { const state = persistedState as Partial; - // Convert old percentage-based values (<100) to pixel widths + // Convert legacy percentage-based widths before general sanitization. if (state.sidebarWidth !== undefined && state.sidebarWidth < 100) { state.sidebarWidth = DEFAULT_SIDEBAR_WIDTH; state.lastOpenSidebarWidth = DEFAULT_SIDEBAR_WIDTH; } - state.rightSidebarTabOrder = normalizeRightSidebarTabOrder( - state.rightSidebarTabOrder, - ); - if (!isRightSidebarTab(state.rightSidebarTab)) { - state.rightSidebarTab = RightSidebarTab.Changes; - } - return state as SidebarState; + return sanitizeSidebarState(state) as SidebarState; }, + merge: (persistedState, currentState) => ({ + ...currentState, + ...sanitizeSidebarState( + ( + persistedState as + | (Partial & { state?: Partial }) + | undefined + )?.state ?? (persistedState as Partial | undefined), + ), + }), version: 2, }, ), diff --git a/apps/desktop/src/resources/build/entitlements.mac.inherit.plist b/apps/desktop/src/resources/build/entitlements.mac.inherit.plist index ea357dd546b..c4611c5bb4a 100644 --- a/apps/desktop/src/resources/build/entitlements.mac.inherit.plist +++ b/apps/desktop/src/resources/build/entitlements.mac.inherit.plist @@ -10,6 +10,8 @@ com.apple.security.device.audio-input + com.apple.security.device.camera + com.apple.security.inherit com.apple.security.automation.apple-events diff --git a/apps/desktop/src/resources/build/entitlements.mac.plist b/apps/desktop/src/resources/build/entitlements.mac.plist index b8f4838aa43..b9de044b2c9 100644 --- a/apps/desktop/src/resources/build/entitlements.mac.plist +++ b/apps/desktop/src/resources/build/entitlements.mac.plist @@ -10,6 +10,8 @@ com.apple.security.device.audio-input + com.apple.security.device.camera + com.apple.security.automation.apple-events diff --git a/packages/local-db/drizzle/0040_illegal_squadron_supreme.sql b/packages/local-db/drizzle/0040_illegal_squadron_supreme.sql new file mode 100644 index 00000000000..5c02567ee27 --- /dev/null +++ b/packages/local-db/drizzle/0040_illegal_squadron_supreme.sql @@ -0,0 +1,11 @@ +CREATE TABLE `browser_site_permissions` ( + `id` text PRIMARY KEY NOT NULL, + `origin` text NOT NULL, + `kind` text NOT NULL, + `value` text DEFAULT 'ask' NOT NULL, + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL +); +--> statement-breakpoint +CREATE INDEX `browser_site_permissions_origin_idx` ON `browser_site_permissions` (`origin`);--> statement-breakpoint +CREATE UNIQUE INDEX `browser_site_permissions_origin_kind_unique` ON `browser_site_permissions` (`origin`,`kind`); \ No newline at end of file diff --git a/packages/local-db/drizzle/meta/0040_snapshot.json b/packages/local-db/drizzle/meta/0040_snapshot.json new file mode 100644 index 00000000000..1a8e0526f2a --- /dev/null +++ b/packages/local-db/drizzle/meta/0040_snapshot.json @@ -0,0 +1,1473 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "edba1e1b-a5d4-4398-ac58-dd28d5d9ba90", + "prevId": "7c9e72d5-9be6-4be5-b6f1-4a32591d30cb", + "tables": { + "browser_history": { + "name": "browser_history", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "favicon_url": { + "name": "favicon_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_visited_at": { + "name": "last_visited_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "visit_count": { + "name": "visit_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + } + }, + "indexes": { + "browser_history_url_unique": { + "name": "browser_history_url_unique", + "columns": [ + "url" + ], + "isUnique": true + }, + "browser_history_url_idx": { + "name": "browser_history_url_idx", + "columns": [ + "url" + ], + "isUnique": false + }, + "browser_history_last_visited_at_idx": { + "name": "browser_history_last_visited_at_idx", + "columns": [ + "last_visited_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "browser_site_permissions": { + "name": "browser_site_permissions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'ask'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "browser_site_permissions_origin_idx": { + "name": "browser_site_permissions_origin_idx", + "columns": [ + "origin" + ], + "isUnique": false + }, + "browser_site_permissions_origin_kind_unique": { + "name": "browser_site_permissions_origin_kind_unique", + "columns": [ + "origin", + "kind" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "organization_members": { + "name": "organization_members", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "organization_members_organization_id_idx": { + "name": "organization_members_organization_id_idx", + "columns": [ + "organization_id" + ], + "isUnique": false + }, + "organization_members_user_id_idx": { + "name": "organization_members_user_id_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "organization_members_organization_id_organizations_id_fk": { + "name": "organization_members_organization_id_organizations_id_fk", + "tableFrom": "organization_members", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_members_user_id_users_id_fk": { + "name": "organization_members_user_id_users_id_fk", + "tableFrom": "organization_members", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "organizations": { + "name": "organizations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "clerk_org_id": { + "name": "clerk_org_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "github_org": { + "name": "github_org", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "organizations_clerk_org_id_unique": { + "name": "organizations_clerk_org_id_unique", + "columns": [ + "clerk_org_id" + ], + "isUnique": true + }, + "organizations_slug_unique": { + "name": "organizations_slug_unique", + "columns": [ + "slug" + ], + "isUnique": true + }, + "organizations_slug_idx": { + "name": "organizations_slug_idx", + "columns": [ + "slug" + ], + "isUnique": false + }, + "organizations_clerk_org_id_idx": { + "name": "organizations_clerk_org_id_idx", + "columns": [ + "clerk_org_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "projects": { + "name": "projects", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "main_repo_path": { + "name": "main_repo_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tab_order": { + "name": "tab_order", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_opened_at": { + "name": "last_opened_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "config_toast_dismissed": { + "name": "config_toast_dismissed", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_branch": { + "name": "default_branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "workspace_base_branch": { + "name": "workspace_base_branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "github_owner": { + "name": "github_owner", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "branch_prefix_mode": { + "name": "branch_prefix_mode", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "branch_prefix_custom": { + "name": "branch_prefix_custom", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "worktree_base_dir": { + "name": "worktree_base_dir", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "hide_image": { + "name": "hide_image", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "icon_url": { + "name": "icon_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "neon_project_id": { + "name": "neon_project_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_app": { + "name": "default_app", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "projects_main_repo_path_idx": { + "name": "projects_main_repo_path_idx", + "columns": [ + "main_repo_path" + ], + "isUnique": false + }, + "projects_last_opened_at_idx": { + "name": "projects_last_opened_at_idx", + "columns": [ + "last_opened_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "settings": { + "name": "settings", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "last_active_workspace_id": { + "name": "last_active_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "terminal_presets": { + "name": "terminal_presets", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "terminal_presets_initialized": { + "name": "terminal_presets_initialized", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "agent_preset_overrides": { + "name": "agent_preset_overrides", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "agent_custom_definitions": { + "name": "agent_custom_definitions", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "selected_ringtone_id": { + "name": "selected_ringtone_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "confirm_on_quit": { + "name": "confirm_on_quit", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "terminal_link_behavior": { + "name": "terminal_link_behavior", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "persist_terminal": { + "name": "persist_terminal", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": true + }, + "auto_apply_default_preset": { + "name": "auto_apply_default_preset", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "branch_prefix_mode": { + "name": "branch_prefix_mode", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "branch_prefix_custom": { + "name": "branch_prefix_custom", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "notification_sounds_muted": { + "name": "notification_sounds_muted", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "notification_volume": { + "name": "notification_volume", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "prevent_agent_sleep": { + "name": "prevent_agent_sleep", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "delete_local_branch": { + "name": "delete_local_branch", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "file_open_mode": { + "name": "file_open_mode", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "show_presets_bar": { + "name": "show_presets_bar", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "use_compact_terminal_add_button": { + "name": "use_compact_terminal_add_button", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "terminal_font_family": { + "name": "terminal_font_family", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "terminal_font_size": { + "name": "terminal_font_size", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "editor_font_family": { + "name": "editor_font_family", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "editor_font_size": { + "name": "editor_font_size", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "show_resource_monitor": { + "name": "show_resource_monitor", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "worktree_base_dir": { + "name": "worktree_base_dir", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "open_links_in_app": { + "name": "open_links_in_app", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_editor": { + "name": "default_editor", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tasks": { + "name": "tasks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status_color": { + "name": "status_color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status_type": { + "name": "status_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status_position": { + "name": "status_position", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "priority": { + "name": "priority", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repository_id": { + "name": "repository_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "assignee_id": { + "name": "assignee_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "creator_id": { + "name": "creator_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "estimate": { + "name": "estimate", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "due_date": { + "name": "due_date", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "labels": { + "name": "labels", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pr_url": { + "name": "pr_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "external_provider": { + "name": "external_provider", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "external_key": { + "name": "external_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "external_url": { + "name": "external_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_synced_at": { + "name": "last_synced_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sync_error": { + "name": "sync_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "completed_at": { + "name": "completed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "tasks_slug_unique": { + "name": "tasks_slug_unique", + "columns": [ + "slug" + ], + "isUnique": true + }, + "tasks_slug_idx": { + "name": "tasks_slug_idx", + "columns": [ + "slug" + ], + "isUnique": false + }, + "tasks_organization_id_idx": { + "name": "tasks_organization_id_idx", + "columns": [ + "organization_id" + ], + "isUnique": false + }, + "tasks_assignee_id_idx": { + "name": "tasks_assignee_id_idx", + "columns": [ + "assignee_id" + ], + "isUnique": false + }, + "tasks_status_idx": { + "name": "tasks_status_idx", + "columns": [ + "status" + ], + "isUnique": false + }, + "tasks_created_at_idx": { + "name": "tasks_created_at_idx", + "columns": [ + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "tasks_organization_id_organizations_id_fk": { + "name": "tasks_organization_id_organizations_id_fk", + "tableFrom": "tasks", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tasks_assignee_id_users_id_fk": { + "name": "tasks_assignee_id_users_id_fk", + "tableFrom": "tasks", + "tableTo": "users", + "columnsFrom": [ + "assignee_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "tasks_creator_id_users_id_fk": { + "name": "tasks_creator_id_users_id_fk", + "tableFrom": "tasks", + "tableTo": "users", + "columnsFrom": [ + "creator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "clerk_id": { + "name": "clerk_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "users_clerk_id_unique": { + "name": "users_clerk_id_unique", + "columns": [ + "clerk_id" + ], + "isUnique": true + }, + "users_email_unique": { + "name": "users_email_unique", + "columns": [ + "email" + ], + "isUnique": true + }, + "users_email_idx": { + "name": "users_email_idx", + "columns": [ + "email" + ], + "isUnique": false + }, + "users_clerk_id_idx": { + "name": "users_clerk_id_idx", + "columns": [ + "clerk_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "workspace_sections": { + "name": "workspace_sections", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tab_order": { + "name": "tab_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_collapsed": { + "name": "is_collapsed", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "workspace_sections_project_id_idx": { + "name": "workspace_sections_project_id_idx", + "columns": [ + "project_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "workspace_sections_project_id_projects_id_fk": { + "name": "workspace_sections_project_id_projects_id_fk", + "tableFrom": "workspace_sections", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "workspaces": { + "name": "workspaces", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "worktree_id": { + "name": "worktree_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tab_order": { + "name": "tab_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_opened_at": { + "name": "last_opened_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_unread": { + "name": "is_unread", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "is_unnamed": { + "name": "is_unnamed", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "deleting_at": { + "name": "deleting_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "port_base": { + "name": "port_base", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "section_id": { + "name": "section_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "workspaces_project_id_idx": { + "name": "workspaces_project_id_idx", + "columns": [ + "project_id" + ], + "isUnique": false + }, + "workspaces_worktree_id_idx": { + "name": "workspaces_worktree_id_idx", + "columns": [ + "worktree_id" + ], + "isUnique": false + }, + "workspaces_last_opened_at_idx": { + "name": "workspaces_last_opened_at_idx", + "columns": [ + "last_opened_at" + ], + "isUnique": false + }, + "workspaces_section_id_idx": { + "name": "workspaces_section_id_idx", + "columns": [ + "section_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "workspaces_project_id_projects_id_fk": { + "name": "workspaces_project_id_projects_id_fk", + "tableFrom": "workspaces", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspaces_worktree_id_worktrees_id_fk": { + "name": "workspaces_worktree_id_worktrees_id_fk", + "tableFrom": "workspaces", + "tableTo": "worktrees", + "columnsFrom": [ + "worktree_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspaces_section_id_workspace_sections_id_fk": { + "name": "workspaces_section_id_workspace_sections_id_fk", + "tableFrom": "workspaces", + "tableTo": "workspace_sections", + "columnsFrom": [ + "section_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "worktrees": { + "name": "worktrees", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "base_branch": { + "name": "base_branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "git_status": { + "name": "git_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "github_status": { + "name": "github_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_by_superset": { + "name": "created_by_superset", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + } + }, + "indexes": { + "worktrees_project_id_idx": { + "name": "worktrees_project_id_idx", + "columns": [ + "project_id" + ], + "isUnique": false + }, + "worktrees_branch_idx": { + "name": "worktrees_branch_idx", + "columns": [ + "branch" + ], + "isUnique": false + } + }, + "foreignKeys": { + "worktrees_project_id_projects_id_fk": { + "name": "worktrees_project_id_projects_id_fk", + "tableFrom": "worktrees", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/packages/local-db/drizzle/meta/_journal.json b/packages/local-db/drizzle/meta/_journal.json index 36d61a892ad..5aaabe96a6f 100644 --- a/packages/local-db/drizzle/meta/_journal.json +++ b/packages/local-db/drizzle/meta/_journal.json @@ -281,6 +281,13 @@ "when": 1775066140568, "tag": "0039_quick_star_brand", "breakpoints": true + }, + { + "idx": 40, + "version": "6", + "when": 1775214744592, + "tag": "0040_illegal_squadron_supreme", + "breakpoints": true } ] -} +} \ No newline at end of file diff --git a/packages/local-db/src/schema/schema.ts b/packages/local-db/src/schema/schema.ts index a1d03a14fc4..7077f8582c6 100644 --- a/packages/local-db/src/schema/schema.ts +++ b/packages/local-db/src/schema/schema.ts @@ -1,4 +1,10 @@ -import { index, integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; +import { + index, + integer, + sqliteTable, + text, + uniqueIndex, +} from "drizzle-orm/sqlite-core"; import { v4 as uuidv4 } from "uuid"; import type { @@ -9,6 +15,8 @@ import type { FileOpenMode, GitHubStatus, GitStatus, + SitePermissionKind, + SitePermissionValue, TerminalLinkBehavior, TerminalPreset, WorkspaceType, @@ -388,3 +396,36 @@ export const browserHistory = sqliteTable( export type InsertBrowserHistory = typeof browserHistory.$inferInsert; export type SelectBrowserHistory = typeof browserHistory.$inferSelect; + +/** + * Browser site permissions table - persists per-origin microphone/camera access + */ +export const browserSitePermissions = sqliteTable( + "browser_site_permissions", + { + id: text("id") + .primaryKey() + .$defaultFn(() => uuidv4()), + origin: text("origin").notNull(), + kind: text("kind").notNull().$type(), + value: text("value").notNull().$type().default("ask"), + createdAt: integer("created_at") + .notNull() + .$defaultFn(() => Date.now()), + updatedAt: integer("updated_at") + .notNull() + .$defaultFn(() => Date.now()), + }, + (table) => [ + index("browser_site_permissions_origin_idx").on(table.origin), + uniqueIndex("browser_site_permissions_origin_kind_unique").on( + table.origin, + table.kind, + ), + ], +); + +export type InsertBrowserSitePermission = + typeof browserSitePermissions.$inferInsert; +export type SelectBrowserSitePermission = + typeof browserSitePermissions.$inferSelect; diff --git a/packages/local-db/src/schema/zod.ts b/packages/local-db/src/schema/zod.ts index 39b6022eabd..691b8aa6720 100644 --- a/packages/local-db/src/schema/zod.ts +++ b/packages/local-db/src/schema/zod.ts @@ -249,3 +249,11 @@ export type BranchPrefixMode = (typeof BRANCH_PREFIX_MODES)[number]; export const FILE_OPEN_MODES = ["split-pane", "new-tab"] as const; export type FileOpenMode = (typeof FILE_OPEN_MODES)[number]; + +export const SITE_PERMISSION_KINDS = ["microphone", "camera"] as const; + +export type SitePermissionKind = (typeof SITE_PERMISSION_KINDS)[number]; + +export const SITE_PERMISSION_VALUES = ["ask", "allow", "block"] as const; + +export type SitePermissionValue = (typeof SITE_PERMISSION_VALUES)[number];