Skip to content
Open
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
6 changes: 6 additions & 0 deletions apps/desktop/src/app/DesktopState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,18 @@ export class DesktopState extends Context.Service<
{
readonly backendReady: Ref.Ref<boolean>;
readonly quitting: Ref.Ref<boolean>;
/**
* While Home is on, the renderer relays its calls to other environments.
* On macOS, closing the window then hides it so the relay keeps running.
*/
readonly keepAliveOnClose: Ref.Ref<boolean>;
}
>()("@t3tools/desktop/app/DesktopState") {}

const make = Effect.all({
backendReady: Ref.make(false),
quitting: Ref.make(false),
keepAliveOnClose: Ref.make(false),
});

export const layer = Layer.effect(DesktopState, make);
2 changes: 2 additions & 0 deletions apps/desktop/src/ipc/DesktopIpcHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ import {
} from "./methods/snapShot.ts";
import * as PreviewIpc from "./methods/preview.ts";
import * as AppActivationIpc from "./methods/appActivation.ts";
import { setKeepAliveOnClose } from "./methods/keepAliveOnClose.ts";
import {
completeLegacyLocalStorage,
takeLegacyLocalStorage,
Expand All @@ -83,6 +84,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers"

yield* ipc.handle(AppActivationIpc.setReady);
yield* ipc.handle(AppActivationIpc.complete);
yield* ipc.handle(setKeepAliveOnClose);

yield* ipc.handleSync(takeLegacyLocalStorage);
yield* ipc.handle(completeLegacyLocalStorage);
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/ipc/channels.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export const PICK_FOLDER_CHANNEL = "desktop:pick-folder";
export const SET_NOTIFICATION_BADGE_CHANNEL = "desktop:set-notification-badge";
export const SET_KEEP_ALIVE_ON_CLOSE_CHANNEL = "desktop:set-keep-alive-on-close";
export const PICK_PROJECT_FAVICON_CHANNEL = "desktop:pick-project-favicon";
export const PICK_THEME_FILES_CHANNEL = "desktop:pick-theme-files";
export const SET_THEME_CHANNEL = "desktop:set-theme";
Expand Down
18 changes: 18 additions & 0 deletions apps/desktop/src/ipc/methods/keepAliveOnClose.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import * as Effect from "effect/Effect";
import * as Ref from "effect/Ref";
import * as Schema from "effect/Schema";

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

/** The renderer turns this on while Home is on, so its relay survives a closed window. */
export const setKeepAliveOnClose = DesktopIpc.makeIpcMethod({
channel: IpcChannels.SET_KEEP_ALIVE_ON_CLOSE_CHANNEL,
payload: Schema.Boolean,
result: Schema.Void,
handler: Effect.fn("desktop.ipc.setKeepAliveOnClose")(function* (keepAlive) {
const state = yield* DesktopState.DesktopState;
yield* Ref.set(state.keepAliveOnClose, keepAlive);
}),
});
2 changes: 2 additions & 0 deletions apps/desktop/src/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,8 @@ contextBridge.exposeInMainWorld("desktopBridge", {
getClientPlatform: () => clientPlatform,
setNotificationBadge: (badge) =>
ipcRenderer.invoke(IpcChannels.SET_NOTIFICATION_BADGE_CHANNEL, badge),
setKeepAliveOnClose: (keepAlive) =>
ipcRenderer.invoke(IpcChannels.SET_KEEP_ALIVE_ON_CLOSE_CHANNEL, keepAlive),
onNotificationBadgeClear: (listener) => {
const handler = () => listener();
ipcRenderer.on(IpcChannels.SET_NOTIFICATION_BADGE_CHANNEL, handler);
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/window/DesktopWindow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -401,6 +401,7 @@ const makeSplashScenario = (createOutcomes: readonly (Electron.BrowserWindow | n
Layer.provide(
Layer.mergeAll(
desktopAssetsLayer,
DesktopState.layer,
desktopEnvironmentLayer,
DesktopAppSettings.layerTest(),
desktopClientSettingsLayer,
Expand Down
13 changes: 12 additions & 1 deletion apps/desktop/src/window/DesktopWindow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { type DesktopSnapShotEvent, DEFAULT_CLIENT_SETTINGS } from "@t3tools/con

import * as DesktopAssets from "../app/DesktopAssets.ts";
import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
import * as DesktopState from "../app/DesktopState.ts";
import { makeComponentLogger } from "../app/DesktopObservability.ts";
import * as ElectronMenu from "../electron/ElectronMenu.ts";
import { getDesktopUrl } from "../electron/ElectronProtocol.ts";
Expand Down Expand Up @@ -322,6 +323,7 @@ export const make = Effect.gen(function* () {
const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings;
const clientSettings = yield* DesktopClientSettings.DesktopClientSettings;
const electronApp = yield* ElectronApp.ElectronApp;
const desktopState = yield* DesktopState.DesktopState;
// Window-side latch for the primary backend's readiness. Set by
// handleBackendReady (driven by the pool's onReady callback), cleared
// by handleBackendNotReady (driven by onShutdown). Only consumed by
Expand Down Expand Up @@ -675,8 +677,17 @@ export const make = Effect.gen(function* () {
window.on("move", scheduleBoundsPersist);
window.on("maximize", scheduleBoundsPersist);
window.on("unmaximize", scheduleBoundsPersist);
window.on("close", () => {
window.on("close", (event) => {
runFork(flushBoundsPersist);
// Hiding keeps the renderer, and with it Home's relay, alive until quit.
if (
environment.platform === "darwin" &&
Ref.getUnsafe(desktopState.keepAliveOnClose) &&
!Ref.getUnsafe(desktopState.quitting)
) {
event.preventDefault();
window.hide();
}
});

if (environment.platform === "darwin") {
Expand Down
23 changes: 18 additions & 5 deletions apps/mobile/src/features/threads/ThreadFeed.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { parseThreadLinkHref } from "@t3tools/shared/threadLinks";
import { ThreadContextDivider } from "./thread-context-divider";
import { ThreadHandoffRow } from "./thread-handoff-row";
import {
Expand Down Expand Up @@ -1238,16 +1239,20 @@ function useMarkdownStyles(
);
}
const linkHref = presentation.href;
// A thread link opens the thread in the app, through the feed's link handler.
const isThreadLink = parseThreadLinkHref(href) !== null;
return (
<MarkdownLinkLabelContext.Provider value="other">
<NativeText
className="underline"
onPress={
linkHref
? () => {
void tryOpenExternalUrl(linkHref, "markdown-link");
}
: undefined
isThreadLink
? () => onLinkPress(href)
: linkHref
? () => {
void tryOpenExternalUrl(linkHref, "markdown-link");
}
: undefined
}
style={{ color: markdownLinkColor }}
>
Expand Down Expand Up @@ -2242,6 +2247,14 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
const userBubbleColor = theme["--color-user-bubble"];
const onMarkdownLinkPress = useCallback(
(href: string) => {
const threadLink = parseThreadLinkHref(href);
Comment thread
t3dotgg marked this conversation as resolved.
if (threadLink) {
navigation.navigate("Thread", {
environmentId: String(threadLink.environmentId),
threadId: String(threadLink.threadId),
});
return;
Comment thread
t3dotgg marked this conversation as resolved.
}
const presentation = resolveMarkdownLinkPresentation(href);
if (presentation.kind === "file") {
const relativePath = resolveWorkspaceRelativeFilePath(
Expand Down
8 changes: 8 additions & 0 deletions apps/server/src/auth/RpcAuthorization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,14 @@ export const RPC_REQUIRED_SCOPES = {
[WS_METHODS.previewAutomationConnect]: AuthOrchestrationOperateScope,
[WS_METHODS.previewAutomationRespond]: AuthOrchestrationOperateScope,
[WS_METHODS.previewAutomationFocusHost]: AuthOrchestrationOperateScope,
[WS_METHODS.homeEnable]: AuthOrchestrationOperateScope,
[WS_METHODS.homeDisable]: AuthOrchestrationOperateScope,
[WS_METHODS.homeStartFresh]: AuthOrchestrationOperateScope,
// Home acts with the user's reach, which orchestration:operate already grants.
[WS_METHODS.fleetInvoke]: AuthOrchestrationOperateScope,
[WS_METHODS.fleetConnect]: AuthOrchestrationOperateScope,
[WS_METHODS.fleetRespond]: AuthOrchestrationOperateScope,
[WS_METHODS.fleetReportWatchEvents]: AuthOrchestrationOperateScope,
[WS_METHODS.subscribePreviewEvents]: AuthOrchestrationReadScope,
[WS_METHODS.subscribeDiscoveredLocalServers]: AuthOrchestrationReadScope,
[WS_METHODS.deviceConfigure]: AuthOrchestrationOperateScope,
Expand Down
85 changes: 85 additions & 0 deletions apps/server/src/home/FleetBroker.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { expect, it } from "@effect/vitest";
import {
EnvironmentId,
type FleetHostRequest,
type FleetInvokeInput,
ThreadId,
} from "@t3tools/contracts";
import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
import * as Deferred from "effect/Deferred";
import * as Effect from "effect/Effect";
import * as Fiber from "effect/Fiber";
import * as Layer from "effect/Layer";
import * as Stream from "effect/Stream";

import * as FleetBroker from "./FleetBroker.ts";

const hub = EnvironmentId.make("hub");
const studio = EnvironmentId.make("studio");
const layer = FleetBroker.layer.pipe(Layer.provide(NodeCrypto.layer));

const invoke: FleetInvokeInput = {
actor: { environmentId: hub, threadId: ThreadId.make("home") },
request: { op: "requests.list", input: { threadId: ThreadId.make("t") } },
};

// Keeps the desktop's stream open, as a live renderer does, and hands back the first request.
const serve = (requests: Stream.Stream<FleetHostRequest>) =>
Effect.gen(function* () {
const first = yield* Deferred.make<FleetHostRequest>();
yield* Stream.runForEach(requests, (request) => Deferred.succeed(first, request)).pipe(
Effect.forkScoped,
);
return first;
});

const host = (clientId: string, connected = true) => ({
clientId,
environments: [{ environmentId: studio, label: "Studio", connected }],
});

it.effect("fails fast when no desktop window relays", () =>
Effect.gen(function* () {
const broker = yield* FleetBroker.FleetBroker;
const error = yield* broker.invoke(studio, invoke).pipe(Effect.asVoid, Effect.flip);
expect(error.code).toBe("environment_unavailable");
}).pipe(Effect.provide(layer)),
);

it.effect("relays a call through the desktop and decodes the answer", () =>
Effect.gen(function* () {
const broker = yield* FleetBroker.FleetBroker;
const firstRequest = yield* serve(yield* broker.connect(host("desktop")));
yield* Effect.yieldNow;
const call = yield* broker.invoke(studio, invoke).pipe(Effect.forkScoped);
const request = yield* Deferred.await(firstRequest);
expect(request).toMatchObject({ environmentId: studio, invoke });
yield* broker.respond({ requestId: request.requestId, result: { requestIds: [] } });
expect(yield* Fiber.join(call)).toEqual({ requestIds: [] });
}).pipe(Effect.scoped, Effect.provide(layer)),
);

it.effect("refuses an environment the desktop reports offline", () =>
Effect.gen(function* () {
const broker = yield* FleetBroker.FleetBroker;
yield* serve(yield* broker.connect(host("desktop", false)));
yield* Effect.yieldNow;
const error = yield* broker.invoke(studio, invoke).pipe(Effect.asVoid, Effect.flip);
expect(error.message).toContain("offline");
}).pipe(Effect.scoped, Effect.provide(layer)),
);

it.effect("a newer desktop registration fails calls the old one still owed", () =>
Effect.gen(function* () {
const broker = yield* FleetBroker.FleetBroker;
const firstRequest = yield* serve(yield* broker.connect(host("old")));
yield* Effect.yieldNow;
const call = yield* broker
.invoke(studio, invoke)
.pipe(Effect.asVoid, Effect.flip, Effect.forkScoped);
yield* Deferred.await(firstRequest);
yield* serve(yield* broker.connect(host("new")));
const error = yield* Fiber.join(call);
expect(error.code).toBe("environment_unavailable");
}).pipe(Effect.scoped, Effect.provide(layer)),
);
Loading
Loading