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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions apps/desktop/src/ipc/DesktopIpcHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ import {
setTheme,
showContextMenu,
} from "./methods/window.ts";
// T3-CUSTOM(expbkt3): user-defined "Open in…" targets.
import { openForkTarget } from "./methods/bkOpenTarget.ts";
import * as PreviewIpc from "./methods/preview.ts";
import { getWslState, setWslBackendEnabled, setWslDistro, setWslOnly } from "./methods/wsl.ts";

Expand Down Expand Up @@ -89,6 +91,8 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers"
yield* ipc.handle(showContextMenu);
yield* ipc.handle(openExternal);
yield* ipc.handle(probeRemoteEditors);
// T3-CUSTOM(expbkt3): user-defined "Open in…" targets.
yield* ipc.handle(openForkTarget);
yield* ipc.handle(getUpdateState);
yield* ipc.handle(setUpdateChannel);
yield* ipc.handle(downloadUpdate);
Expand Down
4 changes: 4 additions & 0 deletions apps/desktop/src/ipc/channels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ export const SET_THEME_CHANNEL = "desktop:set-theme";
export const CONTEXT_MENU_CHANNEL = "desktop:context-menu";
export const OPEN_EXTERNAL_CHANNEL = "desktop:open-external";
export const PROBE_REMOTE_EDITORS_CHANNEL = "desktop:probe-remote-editors";
// T3-CUSTOM(expbkt3): user-defined "Open in…" targets (Obsidian, Finder,
// custom apps). Separate from OPEN_EXTERNAL_CHANNEL, whose allowlist is
// deliberately narrow: http(s) plus VS Code's remote deep-link shape only.
export const OPEN_FORK_TARGET_CHANNEL = "desktop:bk-open-target";
export const MENU_ACTION_CHANNEL = "desktop:menu-action";
export const QUIT_SHORTCUT_CHANNEL = "desktop:quit-shortcut";
export const GET_WINDOW_FULLSCREEN_STATE_CHANNEL = "desktop:get-window-fullscreen-state";
Expand Down
48 changes: 48 additions & 0 deletions apps/desktop/src/ipc/methods/bkOpenTarget.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { describe, expect, it, vi } from "vite-plus/test";
import * as Option from "effect/Option";

// The module under test imports Electron for the handler; the parser it
// exercises is pure, so a stub shell is enough to let the import resolve.
vi.mock("electron", () => ({
shell: { openExternal: vi.fn(), openPath: vi.fn() },
}));

import { parseForkTargetUrl } from "./bkOpenTarget.ts";

const parsed = (url: string) => Option.getOrNull(parseForkTargetUrl(url));

describe("parseForkTargetUrl", () => {
it("accepts an app scheme and leaves it for the OS handler", () => {
expect(parsed("obsidian://open?path=/Users/t/vault/note")).toEqual({
url: new URL("obsidian://open?path=/Users/t/vault/note"),
localPath: null,
});
expect(parsed("zed://ssh/ubuntu@ds1/home/ubuntu/w")?.localPath).toBeNull();
});

it("turns a file URL into a filesystem path to reveal", () => {
expect(parsed("file:///Users/t/BKT3%20Sessions/bk-docs/vinh")?.localPath).toBe(
"/Users/t/BKT3 Sessions/bk-docs/vinh",
);
});

it("rejects a file URL naming another host, which would be a share", () => {
expect(parsed("file://somehost/Users/t")).toBeNull();
});

it("rejects executing schemes", () => {
for (const url of ["javascript:alert(1)", "data:text/html,x", "about:blank", "blob:x"]) {
expect(parsed(url)).toBeNull();
}
});

it("rejects embedded credentials", () => {
expect(parsed("obsidian://user:secret@open?path=/x")).toBeNull();
});

it("rejects anything that is not an absolute URL", () => {
for (const value of ["", "/just/a/path", "not a url", 42, null, undefined]) {
expect(Option.getOrNull(parseForkTargetUrl(value))).toBeNull();
}
});
});
95 changes: 95 additions & 0 deletions apps/desktop/src/ipc/methods/bkOpenTarget.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
/**
* T3-CUSTOM(expbkt3): opening a user-defined "Open in…" target.
*
* Kept apart from `window.openExternal` on purpose. That handler's allowlist is
* intentionally narrow — http(s) plus exactly VS Code's remote deep-link shape
* — and upstream tightened it once already (#7697). Widening it to admit
* arbitrary app schemes would weaken a security boundary shared with every
* link in the transcript; this channel carries only URLs the renderer built
* from the user's own configured targets.
*
* `file:` URLs go to `shell.openPath` rather than `openExternal`, because that
* is what reveals a folder in Finder/Explorer; every other scheme is handed to
* the OS handler (Obsidian, Zed, or whatever the user installed).
*
* @module ipc/methods/bkOpenTarget
*/
import * as Effect from "effect/Effect";
import * as Option from "effect/Option";
import * as Schema from "effect/Schema";

import * as Electron from "electron";

import * as DesktopIpc from "../DesktopIpc.ts";
import * as IpcChannels from "../channels.ts";

/** Schemes that execute rather than open. Never handed to the OS. */
const FORBIDDEN_SCHEMES = new Set(["javascript:", "data:", "vbscript:", "about:", "blob:"]);

export interface ParsedForkTarget {
readonly url: URL;
/** A filesystem path when the target is `file:`, otherwise null. */
readonly localPath: string | null;
}

/**
* Accepts an absolute URL with a plain scheme and no embedded credentials.
*
* Credentials are rejected for the same reason upstream rejects them on editor
* deep links: a `user:password@` authority in a URL handed to the OS is a
* credential-leak shape, never something a legitimate target needs.
*/
export function parseForkTargetUrl(rawUrl: unknown): Option.Option<ParsedForkTarget> {
if (typeof rawUrl !== "string" || rawUrl.length === 0) {
return Option.none();
}

let url: URL;
try {
url = new URL(rawUrl);
} catch {
return Option.none();
}

if (FORBIDDEN_SCHEMES.has(url.protocol) || !/^[a-z][a-z0-9+.-]*:$/.test(url.protocol)) {
return Option.none();
}
if (url.username.length > 0 || url.password.length > 0) {
return Option.none();
}

if (url.protocol === "file:") {
// `file:` must name a path on this machine; a host would make it a share.
if (url.host.length > 0) {
return Option.none();
}
const localPath = decodeURIComponent(url.pathname);
return localPath.length === 0 ? Option.none() : Option.some({ url, localPath });
}

return Option.some({ url, localPath: null });
}

export const openForkTarget = DesktopIpc.makeIpcMethod({
channel: IpcChannels.OPEN_FORK_TARGET_CHANNEL,
payload: Schema.String,
result: Schema.Boolean,
handler: Effect.fn("desktop.ipc.window.openForkTarget")(function* (rawUrl) {
return yield* Option.match(parseForkTargetUrl(rawUrl), {
onNone: () => Effect.succeed(false),
onSome: (target) =>
Effect.promise(() =>
target.localPath === null
? Electron.shell.openExternal(target.url.href).then(
() => true,
() => false,
)
: // openPath resolves to "" on success and an error string otherwise.
Electron.shell.openPath(target.localPath).then(
(error) => error.length === 0,
() => false,
),
),
});
}),
});
2 changes: 2 additions & 0 deletions apps/desktop/src/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,8 @@ contextBridge.exposeInMainWorld("desktopBridge", {
}),
openExternal: (url: string) => ipcRenderer.invoke(IpcChannels.OPEN_EXTERNAL_CHANNEL, url),
probeRemoteEditors: () => ipcRenderer.invoke(IpcChannels.PROBE_REMOTE_EDITORS_CHANNEL, undefined),
// T3-CUSTOM(expbkt3): user-defined "Open in…" targets.
openForkTarget: (url: string) => ipcRenderer.invoke(IpcChannels.OPEN_FORK_TARGET_CHANNEL, url),
onMenuAction: (listener) => {
const wrappedListener = (_event: Electron.IpcRendererEvent, action: unknown) => {
if (typeof action !== "string") return;
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/settings/DesktopClientSettings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ const clientSettings: ClientSettings = {
confirmQuit: true,
confirmThreadArchive: true,
confirmThreadDelete: false,
// T3-CUSTOM(expbkt3): user-defined "Open in…" targets.
openTargets: [],
dismissedProviderUpdateNotificationKeys: [],
diffIgnoreWhitespace: true,
environmentIdentificationMode: "artwork",
Expand Down
18 changes: 18 additions & 0 deletions apps/mobile/src/Stack.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import { PlanReviewCommentSheet } from "./features/planreview/PlanReviewCommentS
import { ThreadMembersSheet } from "./features/members/ThreadMembersSheet";
// T3-CUSTOM(expbkt3): per-thread API-level cost.
import { ThreadUsageSheet } from "./features/threadusage/ThreadUsageSheet";
import { EnvironmentAppearanceSheet } from "./features/environments/EnvironmentAppearanceSheet";
import { ThreadTerminalRouteScreen } from "./features/terminal/ThreadTerminalRouteScreen";
import { GitBranchesSheet } from "./features/threads/git/GitBranchesSheet";
import { GitCommitSheet } from "./features/threads/git/GitCommitSheet";
Expand Down Expand Up @@ -350,6 +351,10 @@ const WORKSPACE_OVERLAY_ROUTES = new Set([
"SettingsSheet",
"ThreadReviewComment",
"ThreadSettingsSheet",
// T3-CUSTOM(expbkt3): fork sheets that float over the workspace.
"ThreadPlanReviewComment",
"ThreadUsage",
"EnvironmentAppearance",
]);

/**
Expand Down Expand Up @@ -554,6 +559,19 @@ export const RootStack = createNativeStackNavigator({
headerShown: false,
},
}),
// T3-CUSTOM(expbkt3): per-environment nickname, icon and colour.
EnvironmentAppearance: createNativeStackScreen({
screen: EnvironmentAppearanceSheet,
linking: "environment-appearance",
options: {
...(Platform.OS === "android"
? { presentation: "fullScreenModal" as const }
: FORM_SHEET_PRESENTATION_OPTIONS),
sheetAllowedDetents: Platform.OS === "android" ? undefined : [0.7, 0.95],
sheetGrabberVisible: Platform.OS !== "android",
headerShown: false,
},
}),
// T3-CUSTOM(expbkt3): END native plan review
ThreadFiles: createNativeStackScreen({
screen: ThreadFilesTreeScreen,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import { cn } from "../../lib/cn";
import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic";
import type { ConnectedEnvironmentSummary } from "../../state/remote-runtime-types";
import { ConnectionStatusDot } from "./ConnectionStatusDot";
// T3-CUSTOM(expbkt3): per-environment appearance entry point.
import { EnvironmentAppearanceButton } from "../environments/EnvironmentAppearanceButton";

function connectionStatusLabel(environment: ConnectedEnvironmentSummary): string | null {
return connectionStatusText({
Expand Down Expand Up @@ -168,6 +170,8 @@ export function ConnectionEnvironmentRow(props: {
</>
)}

{/* T3-CUSTOM(expbkt3): nickname, icon and colour for this environment. */}
<EnvironmentAppearanceButton environmentId={props.environment.environmentId} />
<View className="flex-row justify-end gap-2">
{props.environment.isRelayManaged ? null : (
<Pressable
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
// T3-CUSTOM(expbkt3): the row that opens the appearance sheet from Connections.
// Self-contained so the seam inside the upstream connection row is one element.
import { useNavigation } from "@react-navigation/native";
import type { EnvironmentId } from "@t3tools/contracts";
import { Pressable, View } from "react-native";

import { AppText as Text } from "../../components/AppText";
import { SymbolView } from "../../components/AppSymbol";
import { useThemeColor } from "../../lib/useThemeColor";
import { EnvironmentBadge } from "./EnvironmentBadge";
import { useEnvironmentAppearance } from "./useEnvironmentAppearance";

export function EnvironmentAppearanceButton(props: { readonly environmentId: EnvironmentId }) {
const navigation = useNavigation();
const appearance = useEnvironmentAppearance(props.environmentId);
const mutedColor = useThemeColor("--color-icon-subtle");
if (appearance === null) return null;
return (
<Pressable
accessibilityRole="button"
className="flex-row items-center gap-3 rounded-[14px] border border-input-border bg-input px-3.5 py-2.5 active:opacity-70"
onPress={() =>
navigation.navigate("EnvironmentAppearance", { environmentId: props.environmentId })
}
>
<EnvironmentBadge appearance={appearance} variant="icon" size={12} />
<View className="min-w-0 flex-1">
<Text className="text-sm font-t3-medium text-foreground">Appearance</Text>
<Text className="text-xs text-foreground-muted" numberOfLines={1}>
{appearance.customized ? appearance.name : "Nickname, icon and colour"}
</Text>
</View>
<SymbolView name="chevron.right" size={12} tintColor={mutedColor} type="monochrome" />
</Pressable>
);
}
Loading
Loading