Skip to content

Commit 426e919

Browse files
Merge branch 'main' into fix/issue-25021
2 parents c4b8d1e + 07a1e79 commit 426e919

100 files changed

Lines changed: 4618 additions & 509 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
import { assert, it } from "@effect/vitest";
2+
import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
3+
import * as Deferred from "effect/Deferred";
4+
import * as Effect from "effect/Effect";
5+
import * as Layer from "effect/Layer";
6+
import * as FileSystem from "effect/FileSystem";
7+
import * as Path from "effect/Path";
8+
import * as HttpClient from "effect/http/HttpClient";
9+
import * as NetService from "@t3tools/shared/Net";
10+
import * as Fiber from "effect/Fiber";
11+
import * as Option from "effect/Option";
12+
import * as Ref from "effect/Ref";
13+
import * as Stream from "effect/Stream";
14+
import { vi } from "vite-plus/test";
15+
16+
vi.mock("electron", () => ({}));
17+
vi.mock("@clerk/electron", () => ({ createClerkBridge: vi.fn() }));
18+
vi.mock("@clerk/electron/storage", () => ({ storage: vi.fn() }));
19+
vi.mock("../ipc/DesktopIpcHandlers.ts", () => ({
20+
installDesktopIpcHandlers: () => Effect.void,
21+
}));
22+
vi.mock("../updates/DesktopRemoteUpdates.ts", () => ({ listen: Effect.void }));
23+
vi.mock("./DesktopStartupRecovery.ts", () => ({ listen: Effect.void }));
24+
25+
import * as DesktopApp from "./DesktopApp.ts";
26+
import * as DesktopAppActivation from "./DesktopAppActivation.ts";
27+
import * as DesktopAppIdentity from "./DesktopAppIdentity.ts";
28+
import * as DesktopClerk from "./DesktopClerk.ts";
29+
import * as DesktopEnvironment from "./DesktopEnvironment.ts";
30+
import * as DesktopLegacyLocalStorage from "./DesktopLegacyLocalStorage.ts";
31+
import * as DesktopLifecycle from "./DesktopLifecycle.ts";
32+
import * as DesktopLinuxUrlHandler from "./DesktopLinuxUrlHandler.ts";
33+
import * as DesktopPreReadyPlatform from "./DesktopPreReadyPlatform.ts";
34+
import * as DesktopShutdown from "./DesktopShutdown.ts";
35+
import * as DesktopState from "./DesktopState.ts";
36+
import * as DesktopBackendPool from "../backend/DesktopBackendPool.ts";
37+
import * as DesktopServerExposure from "../backend/DesktopServerExposure.ts";
38+
import * as ElectronApp from "../electron/ElectronApp.ts";
39+
import * as ElectronDialog from "../electron/ElectronDialog.ts";
40+
import * as ElectronProtocol from "../electron/ElectronProtocol.ts";
41+
import * as ElectronSafeStorage from "../electron/ElectronSafeStorage.ts";
42+
import * as PreviewPasskeys from "../preview/Passkeys.ts";
43+
import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts";
44+
import * as DesktopShellEnvironment from "../shell/DesktopShellEnvironment.ts";
45+
import * as DesktopSnapShot from "../snapShot/DesktopSnapShot.ts";
46+
import * as DesktopRendererHistory from "../telemetry/DesktopRendererHistory.ts";
47+
import * as DesktopUpdates from "../updates/DesktopUpdates.ts";
48+
import * as DesktopApplicationMenu from "../window/DesktopApplicationMenu.ts";
49+
import * as DesktopWindow from "../window/DesktopWindow.ts";
50+
import * as DesktopWslBackend from "../wsl/DesktopWslBackend.ts";
51+
52+
import * as BrowserImport from "../preview/BrowserImport/BrowserImport.ts";
53+
import * as DesktopBackendConfiguration from "../backend/DesktopBackendConfiguration.ts";
54+
import * as DesktopCliCommand from "./DesktopCliCommand.ts";
55+
import * as DesktopClientSettings from "../settings/DesktopClientSettings.ts";
56+
import * as DesktopConnectionCatalogStore from "./DesktopConnectionCatalogStore.ts";
57+
import * as DesktopIpc from "../ipc/DesktopIpc.ts";
58+
import * as DesktopLocalEnvironmentAuth from "../backend/DesktopLocalEnvironmentAuth.ts";
59+
import * as DesktopManagedPolicy from "./DesktopManagedPolicy.ts";
60+
import * as DesktopSshEnvironment from "../ssh/DesktopSshEnvironment.ts";
61+
import * as DesktopSshPasswordPrompts from "../ssh/DesktopSshPasswordPrompts.ts";
62+
import * as DesktopTelemetryPublisher from "../telemetry/DesktopTelemetryPublisher.ts";
63+
import * as DesktopWebLinks from "./DesktopWebLinks.ts";
64+
import * as DesktopWslEnvironment from "../wsl/DesktopWslEnvironment.ts";
65+
import * as ElectronMenu from "../electron/ElectronMenu.ts";
66+
import * as ElectronShell from "../electron/ElectronShell.ts";
67+
import * as ElectronTheme from "../electron/ElectronTheme.ts";
68+
import * as ElectronWindow from "../electron/ElectronWindow.ts";
69+
import * as MacPermissions from "../permissions/MacPermissions.ts";
70+
import * as PreviewManager from "../preview/Manager.ts";
71+
72+
it.effect(
73+
"starts the backend and window while Linux registration is stalled and cancels it on shutdown",
74+
() =>
75+
Effect.gen(function* () {
76+
const registrationStarted = yield* Deferred.make<void>();
77+
const releaseRegistration = yield* Deferred.make<void>();
78+
const registrationStopped = yield* Deferred.make<void>();
79+
const windowStarted = yield* Deferred.make<void>();
80+
const quitting = yield* Ref.make(false);
81+
const events: string[] = [];
82+
const record = (event: string) =>
83+
Effect.sync(() => {
84+
events.push(event);
85+
});
86+
const runApp = DesktopApp.program
87+
.pipe(
88+
Effect.provide(NodeCrypto.layer),
89+
Effect.provideService(DesktopAppIdentity.DesktopAppIdentity, {
90+
resolveUserDataPath: Effect.succeed("/isolated/userdata"),
91+
configure: record("identity"),
92+
} as DesktopAppIdentity.DesktopAppIdentity["Service"]),
93+
Effect.provideService(ElectronApp.ElectronApp, {
94+
setPath: () => Effect.void,
95+
whenReady: record("ready"),
96+
} as unknown as ElectronApp.ElectronApp["Service"]),
97+
Effect.provideService(ElectronDialog.ElectronDialog, {
98+
showErrorBox: () => Effect.die("unexpected fatal startup error"),
99+
} as unknown as ElectronDialog.ElectronDialog["Service"]),
100+
Effect.provideService(DesktopEnvironment.DesktopEnvironment, {
101+
platform: "linux",
102+
isDevelopment: false,
103+
isPackaged: true,
104+
configuredBackendPort: Option.some(3773),
105+
defaultDesktopSettings: { serverExposureMode: "local-only" },
106+
} as unknown as DesktopEnvironment.DesktopEnvironment["Service"]),
107+
Effect.provideService(DesktopPreReadyPlatform.DesktopPreReadyElectronOptions, {
108+
linux: null,
109+
linuxPasswordStoreCommandLine: "basic",
110+
}),
111+
Effect.provideService(DesktopShellEnvironment.DesktopShellEnvironment, {
112+
installIntoProcess: Effect.succeed(true),
113+
awaitDiscovery: Effect.succeed(true),
114+
updates: Stream.empty,
115+
}),
116+
Effect.provideService(DesktopAppSettings.DesktopAppSettings, {
117+
load: Effect.void,
118+
get: Effect.succeed({
119+
localEnvironmentEnabled: true,
120+
serverExposureMode: "local-only",
121+
}),
122+
} as unknown as DesktopAppSettings.DesktopAppSettings["Service"]),
123+
Effect.provideService(DesktopLifecycle.DesktopLifecycle, {
124+
register: Effect.void,
125+
relaunch: () => Effect.void,
126+
}),
127+
Effect.provideService(DesktopClerk.DesktopClerk, {
128+
configure: Effect.void,
129+
} as DesktopClerk.DesktopClerk["Service"]),
130+
Effect.provideService(ElectronSafeStorage.ElectronSafeStorage, {
131+
selectedStorageBackend: Effect.succeedNone,
132+
} as unknown as ElectronSafeStorage.ElectronSafeStorage["Service"]),
133+
Effect.provideService(PreviewPasskeys.PreviewPasskeys, {
134+
configure: Effect.void,
135+
} as PreviewPasskeys.PreviewPasskeys["Service"]),
136+
Effect.provideService(DesktopApplicationMenu.DesktopApplicationMenu, {
137+
configure: Effect.void,
138+
}),
139+
Effect.provideService(DesktopUpdates.DesktopUpdates, {
140+
configure: Effect.void,
141+
} as unknown as DesktopUpdates.DesktopUpdates["Service"]),
142+
Effect.provideService(DesktopLinuxUrlHandler.DesktopLinuxUrlHandler, {
143+
register: Deferred.succeed(registrationStarted, undefined).pipe(
144+
Effect.andThen(Deferred.await(releaseRegistration)),
145+
Effect.ensuring(Deferred.succeed(registrationStopped, undefined)),
146+
),
147+
}),
148+
)
149+
.pipe(
150+
Effect.provideService(ElectronProtocol.ElectronProtocol, {
151+
registerDesktopProtocol: () => Effect.void,
152+
} as unknown as ElectronProtocol.ElectronProtocol["Service"]),
153+
Effect.provideService(DesktopLegacyLocalStorage.DesktopLegacyLocalStorage, {
154+
load: () => Effect.void,
155+
} as unknown as DesktopLegacyLocalStorage.DesktopLegacyLocalStorage["Service"]),
156+
Effect.provideService(DesktopSnapShot.DesktopSnapShot, {
157+
initialize: Effect.void,
158+
} as DesktopSnapShot.DesktopSnapShot["Service"]),
159+
Effect.provideService(DesktopState.DesktopState, {
160+
quitting,
161+
} as DesktopState.DesktopState["Service"]),
162+
Effect.provideService(DesktopWindow.DesktopWindow, {
163+
ensureMain: record("window").pipe(
164+
Effect.andThen(Deferred.succeed(windowStarted, undefined)),
165+
),
166+
} as unknown as DesktopWindow.DesktopWindow["Service"]),
167+
Effect.provideService(DesktopBackendPool.DesktopBackendPool, {
168+
primary: Effect.succeed({
169+
start: record("backend"),
170+
currentConfig: Effect.succeedSome({ preflightFailure: Option.none() }),
171+
}),
172+
list: Effect.succeed([]),
173+
} as unknown as DesktopBackendPool.DesktopBackendPool["Service"]),
174+
Effect.provideService(DesktopServerExposure.DesktopServerExposure, {
175+
configureFromSettings: () => Effect.succeed({ mode: "local-only" }),
176+
backendConfig: Effect.succeed({ httpBaseUrl: new URL("http://127.0.0.1:3773") }),
177+
} as unknown as DesktopServerExposure.DesktopServerExposure["Service"]),
178+
Effect.provideService(DesktopWslBackend.DesktopWslBackend, {
179+
reconcile: Effect.void,
180+
} as DesktopWslBackend.DesktopWslBackend["Service"]),
181+
Effect.provideService(DesktopAppActivation.DesktopAppActivation, {
182+
start: Effect.void,
183+
} as unknown as DesktopAppActivation.DesktopAppActivation["Service"]),
184+
Effect.provideService(DesktopRendererHistory.DesktopRendererHistory, {
185+
shutdown: Effect.void,
186+
} as DesktopRendererHistory.DesktopRendererHistory["Service"]),
187+
);
188+
const app = yield* runApp.pipe(
189+
Effect.provide(
190+
Layer.mergeAll(
191+
Layer.mock(BrowserImport.BrowserImport, {}),
192+
Layer.mock(DesktopBackendConfiguration.DesktopBackendConfiguration, {}),
193+
Layer.mock(DesktopCliCommand.DesktopCliCommand, {}),
194+
Layer.mock(DesktopClientSettings.DesktopClientSettings, {}),
195+
Layer.mock(DesktopConnectionCatalogStore.DesktopConnectionCatalogStore, {}),
196+
Layer.mock(DesktopIpc.DesktopIpc, {}),
197+
Layer.mock(DesktopLocalEnvironmentAuth.DesktopLocalEnvironmentAuth, {}),
198+
Layer.mock(DesktopManagedPolicy.DesktopManagedPolicy, {}),
199+
Layer.mock(DesktopSshEnvironment.DesktopSshEnvironment, {}),
200+
Layer.mock(DesktopSshPasswordPrompts.DesktopSshPasswordPrompts, {}),
201+
Layer.mock(DesktopTelemetryPublisher.DesktopTelemetryPublisher, {}),
202+
Layer.mock(DesktopWebLinks.DesktopWebLinks, {}),
203+
Layer.mock(DesktopWslEnvironment.DesktopWslEnvironment, {}),
204+
Layer.mock(ElectronMenu.ElectronMenu, {}),
205+
Layer.mock(ElectronShell.ElectronShell, {}),
206+
Layer.mock(ElectronTheme.ElectronTheme, {}),
207+
Layer.mock(ElectronWindow.ElectronWindow, {}),
208+
Layer.mock(MacPermissions.MacPermissions, {}),
209+
Layer.mock(PreviewManager.PreviewManager, {
210+
isBrowserPartition: () => {
211+
throw new Error("unexpected browser partition lookup");
212+
},
213+
}),
214+
FileSystem.layerNoop({}),
215+
Layer.mock(NetService.NetService, {}),
216+
Path.layer,
217+
Layer.succeed(
218+
HttpClient.HttpClient,
219+
HttpClient.make(() => Effect.die("unexpected HTTP request")),
220+
),
221+
),
222+
),
223+
Effect.forkScoped,
224+
);
225+
yield* Deferred.await(registrationStarted);
226+
yield* Deferred.await(windowStarted);
227+
assert.deepEqual(events, ["identity", "ready", "identity", "backend", "window"]);
228+
assert.isFalse(yield* Deferred.isDone(registrationStopped));
229+
yield* (yield* DesktopShutdown.DesktopShutdown).request;
230+
yield* Fiber.join(app);
231+
assert.isTrue(yield* Deferred.isDone(registrationStopped));
232+
}).pipe(Effect.provide(DesktopShutdown.layer)),
233+
);

‎apps/desktop/src/app/DesktopApp.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -337,7 +337,8 @@ const startup = Effect.gen(function* () {
337337
yield* updates.configure;
338338
yield* DesktopRemoteUpdates.listen;
339339
yield* DesktopStartupRecovery.listen;
340-
yield* linuxUrlHandler.register;
340+
// Portal identity is prepared pre-ready; MIME helpers must not gate the backend or window.
341+
yield* linuxUrlHandler.register.pipe(Effect.forkScoped);
341342
yield* bootstrap.pipe(Effect.catchCause((cause) => fatalStartupCause("bootstrap", cause)));
342343
}).pipe(Effect.withSpan("desktop.startup"));
343344

‎apps/mobile/src/features/cloud/cloud-drafts.ts‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,13 @@ export const removeCloudEnvironments = createRuntimeCommand(connectionAtomRuntim
3030
);
3131
// Backup failure must retain local file owners so a later sign-in can retry.
3232
yield* Effect.tryPromise({
33-
try: () => archiveCloudComposerDrafts(accountId, environmentIds),
33+
// Resolve draft owners against every environment, not just the removed ones.
34+
try: () =>
35+
archiveCloudComposerDrafts(
36+
accountId,
37+
environmentIds,
38+
[...entries.values()].map((entry) => entry.target.environmentId),
39+
),
3440
catch: (cause) =>
3541
new CloudDraftArchiveError({
3642
environmentCount: environmentIds.size,

‎apps/mobile/src/lib/composerAttachmentUploadQueue.test.ts‎

Lines changed: 27 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -253,7 +253,7 @@ describe("draft upload scope and offline submission", () => {
253253
const threadId = ThreadId.make("thread:1");
254254
const prefix = questionAttachmentDraftPrefix(environment, threadId);
255255
const key = questionAttachmentDraftKey(environment, threadId, requestId, "question:1");
256-
expect(composerDraftEnvironmentId(key, [])).toBe(environment);
256+
expect(composerDraftEnvironmentId(key, [environment], [])).toBe(environment);
257257
expect(key.startsWith(prefix)).toBe(true);
258258
for (const suffix of ["-extra", ":extra", "/extra", "%extra"]) {
259259
const otherKey = questionAttachmentDraftKey(
@@ -263,20 +263,34 @@ describe("draft upload scope and offline submission", () => {
263263
"question:1",
264264
);
265265
expect(otherKey.startsWith(prefix)).toBe(false);
266-
expect(composerDraftEnvironmentId(otherKey, [])).toBe(environment);
266+
expect(composerDraftEnvironmentId(otherKey, [environment], [])).toBe(environment);
267267
}
268268
}
269269
});
270270
it("resolves thread, new-task, and queued-task drafts without crossing environments", () => {
271-
expect(composerDraftEnvironmentId("environment-1:thread", [])).toBe(environmentId);
272-
expect(composerDraftEnvironmentId("new-task:environment-1:project", [])).toBe(environmentId);
271+
expect(composerDraftEnvironmentId("environment-1:thread", [environmentId], [])).toBe(
272+
environmentId,
273+
);
274+
expect(composerDraftEnvironmentId("new-task:environment-1:project", [], [])).toBe(
275+
environmentId,
276+
);
273277
expect(
274-
composerDraftEnvironmentId("pending-task:message", [{ messageId: "message", environmentId }]),
278+
composerDraftEnvironmentId(
279+
"pending-task:message",
280+
[],
281+
[{ messageId: "message", environmentId }],
282+
),
275283
).toBe(environmentId);
276-
expect(composerDraftEnvironmentId("pending-task:missing", [])).toBeNull();
284+
expect(composerDraftEnvironmentId("pending-task:missing", [], [])).toBeNull();
277285
const colonEnvironment = EnvironmentId.make("a:vcs-status:b");
278-
expect(composerDraftEnvironmentId(`${colonEnvironment}:thread`, [])).toBe(colonEnvironment);
279-
expect(composerDraftEnvironmentId(`new-task:${colonEnvironment}:project`, [])).toBe(
286+
expect(
287+
composerDraftEnvironmentId(
288+
`${colonEnvironment}:thread`,
289+
[EnvironmentId.make("a"), colonEnvironment],
290+
[],
291+
),
292+
).toBe(colonEnvironment);
293+
expect(composerDraftEnvironmentId(`new-task:${colonEnvironment}:project`, [], [])).toBe(
280294
colonEnvironment,
281295
);
282296
});
@@ -289,9 +303,11 @@ describe("draft upload scope and offline submission", () => {
289303
createdAt: "2026-09-05T00:00:00.000Z",
290304
},
291305
};
292-
expect(composerDraftEnvironmentId("new-task:abc123-def456", [], stamped)).toBe(environmentId);
293-
expect(composerDraftEnvironmentId("new-task:abc123-def456", [])).toBeNull();
294-
expect(composerDraftEnvironmentId("new-task:environment-2:project", [], stamped)).toBe(
306+
expect(composerDraftEnvironmentId("new-task:abc123-def456", [], [], stamped)).toBe(
307+
environmentId,
308+
);
309+
expect(composerDraftEnvironmentId("new-task:abc123-def456", [], [])).toBeNull();
310+
expect(composerDraftEnvironmentId("new-task:environment-2:project", [], [], stamped)).toBe(
295311
environmentId,
296312
);
297313
});

‎apps/mobile/src/lib/composerAttachmentUploadQueue.ts‎

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,10 @@ export function composerAttachmentUploadKey(
2121
return `${environmentId}:${attachmentId}`;
2222
}
2323

24+
/** Environment and thread ids may both contain colons, so a thread draft key resolves to the longest known environment it starts with. */
2425
export function composerDraftEnvironmentId(
2526
draftKey: string,
27+
environmentIds: Iterable<EnvironmentId>,
2628
queuedMessages: ReadonlyArray<{
2729
readonly messageId: string;
2830
readonly environmentId: EnvironmentId;
@@ -42,8 +44,16 @@ export function composerDraftEnvironmentId(
4244
const legacy = parseLegacyNewTaskDraftKey(draftKey);
4345
return legacy === null ? null : EnvironmentId.make(legacy.environmentId);
4446
}
45-
const separator = draftKey.lastIndexOf(":");
46-
return separator > 0 ? EnvironmentId.make(draftKey.slice(0, separator)) : null;
47+
let match: EnvironmentId | null = null;
48+
for (const environmentId of environmentIds) {
49+
if (
50+
draftKey.startsWith(`${environmentId}:`) &&
51+
(match === null || environmentId.length > match.length)
52+
) {
53+
match = environmentId;
54+
}
55+
}
56+
return match;
4757
}
4858

4959
type UploadServerConfig = {

‎apps/mobile/src/lib/composerContextClipboard.test.ts‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,15 @@ vi.mock("expo-file-system", () => ({
2929
}));
3030
vi.mock("../state/atom-registry", () => ({
3131
appAtomRegistry: {
32-
get: () => ({ _tag: "Some", value: { httpBaseUrl: "https://source.example" } }),
32+
get: (atom: unknown) =>
33+
atom === "environments"
34+
? []
35+
: { _tag: "Some", value: { httpBaseUrl: "https://source.example" } },
3336
},
3437
}));
38+
vi.mock("../state/workspace", () => ({
39+
workspaceConnections: { environmentsAtom: "environments" },
40+
}));
3541
vi.mock("../state/session", () => ({
3642
environmentSession: { preparedConnectionValueAtom: vi.fn() },
3743
}));

‎apps/mobile/src/lib/composerContextClipboard.ts‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import * as Option from "effect/Option";
2020
import { appAtomRegistry } from "../state/atom-registry";
2121
import { assetEnvironment } from "../state/assets";
2222
import { environmentSession } from "../state/session";
23+
import { workspaceConnections } from "../state/workspace";
2324
import { downloadAttachmentForPreview } from "./attachmentDownload";
2425
import {
2526
persistComposerAttachmentFile,
@@ -137,7 +138,13 @@ async function importAttachment(
137138
): Promise<DraftComposerAttachment> {
138139
await waitForComposerDraftsLoaded();
139140
checkAborted(signal);
140-
const local = findLocalComposerClipboardAttachment(environmentId, record.attachmentId);
141+
const local = findLocalComposerClipboardAttachment(
142+
appAtomRegistry
143+
.get(workspaceConnections.environmentsAtom)
144+
.map((environment) => environment.environmentId),
145+
environmentId,
146+
record.attachmentId,
147+
);
141148
if (local) {
142149
if (!local.fileUri && local.type === "image" && local.dataUrl)
143150
return {

0 commit comments

Comments
 (0)