Skip to content

Commit 1c62ab6

Browse files
authored
Merge branch 'main' into fix/windows-postinstall-cross-platform
2 parents 504c2b6 + f640f6f commit 1c62ab6

80 files changed

Lines changed: 2782 additions & 269 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.

apps/code/runtime-dependencies.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,8 @@ export const requiredNativeModules = [
3737
"better-sqlite3",
3838
];
3939

40-
// file-icon (and its p-map dependency) is only used on macOS.
41-
export const macOnlyNativeModules = ["file-icon", "p-map"];
40+
// file-icon is only used on macOS.
41+
export const macOnlyNativeModules = ["file-icon"];
4242

4343
// The subset that ships compiled .node binaries and must be unpacked from asar.
4444
const asarUnpackModules = [
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { describe, expect, it } from "vitest";
2+
import { resolveMacFileIconBinary } from "./electron-file-icon";
3+
4+
describe("resolveMacFileIconBinary", () => {
5+
it("resolves the packaged extractor outside the ASAR", () => {
6+
expect(
7+
resolveMacFileIconBinary(
8+
"/Applications/PostHog Code.app/Contents/Resources/app.asar",
9+
true,
10+
"/unused/node_modules/file-icon/index.js",
11+
),
12+
).toBe(
13+
"/Applications/PostHog Code.app/Contents/Resources/app.asar.unpacked/node_modules/file-icon/file-icon",
14+
);
15+
});
16+
});
Lines changed: 38 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,29 @@
1+
import { execFile } from "node:child_process";
2+
import path from "node:path";
13
import type { IFileIcon } from "@posthog/platform/file-icon";
24
import { app } from "electron";
35
import { injectable } from "inversify";
46

5-
type FileIconModule = typeof import("file-icon");
7+
const FILE_ICON_MAX_BUFFER_BYTES = 100 * 1024 * 1024;
8+
9+
export function resolveMacFileIconBinary(
10+
appPath: string,
11+
isPackaged: boolean,
12+
modulePath: string,
13+
): string {
14+
const resolvedModulePath = isPackaged
15+
? path.join(`${appPath}.unpacked`, "node_modules", "file-icon", "index.js")
16+
: modulePath;
17+
return path.join(path.dirname(resolvedModulePath), "file-icon");
18+
}
619

720
@injectable()
821
export class ElectronFileIcon implements IFileIcon {
9-
private fileIconModule: FileIconModule | undefined;
10-
1122
public async getAsDataUrl(filePath: string): Promise<string | null> {
1223
try {
1324
if (process.platform === "darwin") {
14-
const mod = await this.loadFileIconModule();
15-
const uint8Array = await mod.fileIconToBuffer(filePath, { size: 64 });
16-
const base64 = Buffer.from(uint8Array).toString("base64");
25+
const buffer = await this.getMacFileIcon(filePath);
26+
const base64 = buffer.toString("base64");
1727
return `data:image/png;base64,${base64}`;
1828
}
1929

@@ -25,10 +35,27 @@ export class ElectronFileIcon implements IFileIcon {
2535
}
2636
}
2737

28-
private async loadFileIconModule(): Promise<FileIconModule> {
29-
if (!this.fileIconModule) {
30-
this.fileIconModule = await import("file-icon");
31-
}
32-
return this.fileIconModule;
38+
private getMacFileIcon(filePath: string): Promise<Buffer> {
39+
const binaryPath = resolveMacFileIconBinary(
40+
app.getAppPath(),
41+
app.isPackaged,
42+
require.resolve("file-icon"),
43+
);
44+
const input = JSON.stringify([{ appOrPID: filePath, size: 64 }]);
45+
46+
return new Promise((resolve, reject) => {
47+
execFile(
48+
binaryPath,
49+
[input],
50+
{ encoding: "buffer", maxBuffer: FILE_ICON_MAX_BUFFER_BYTES },
51+
(error, stdout) => {
52+
if (error) {
53+
reject(error);
54+
return;
55+
}
56+
resolve(Buffer.from(stdout));
57+
},
58+
);
59+
});
3360
}
3461
}

apps/code/src/main/zoom.test.ts

Lines changed: 64 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,6 @@ import { adjustWindowZoom, restoreWindowZoom, setupWindowZoom } from "./zoom";
2222
class FakeWebContents extends EventEmitter {
2323
public zoomLevel = 0;
2424

25-
public getZoomLevel(): number {
26-
return this.zoomLevel;
27-
}
28-
2925
public setZoomLevel(level: number): void {
3026
this.zoomLevel = level;
3127
}
@@ -89,50 +85,100 @@ describe("window zoom", () => {
8985
expect(window.webContents.zoomLevel).toBe(0.5);
9086
});
9187

92-
it("persists wheel zoom after Chromium updates its level", () => {
88+
it.each([
89+
["in", 1],
90+
["out", 0],
91+
] as const)(
92+
"applies wheel zoom %s from the persisted level",
93+
(direction, expected) => {
94+
const window = createWindow();
95+
setupWindowZoom(window);
96+
const event = { preventDefault: vi.fn() };
97+
98+
window.webContents.emit("zoom-changed", event, direction);
99+
vi.runAllTimers();
100+
101+
expect({
102+
prevented: event.preventDefault.mock.calls.length,
103+
zoomLevel: window.webContents.zoomLevel,
104+
saved: store.save.mock.calls,
105+
}).toEqual({
106+
prevented: 1,
107+
zoomLevel: expected,
108+
saved: [[expected]],
109+
});
110+
},
111+
);
112+
113+
it("keeps wheel zoom after resizing", () => {
93114
const window = createWindow();
94115
setupWindowZoom(window);
95116

96-
window.webContents.emit("zoom-changed");
97-
window.webContents.zoomLevel = 1.5;
117+
window.webContents.emit("zoom-changed", { preventDefault: vi.fn() }, "in");
118+
window.emit("resized");
98119
vi.runAllTimers();
99120

100-
expect(store.save).toHaveBeenCalledWith(1.5);
121+
expect({
122+
zoomLevel: window.webContents.zoomLevel,
123+
saved: store.save.mock.calls,
124+
}).toEqual({
125+
zoomLevel: 1,
126+
saved: [[1]],
127+
});
101128
});
102129

103-
it("waits for native zoom before applying a menu adjustment", () => {
130+
it("keeps wheel zoom after a renderer reload", () => {
104131
const window = createWindow();
105132
setupWindowZoom(window);
106133

107-
window.webContents.emit("zoom-changed");
108-
window.webContents.zoomLevel = 1.5;
134+
window.webContents.emit("zoom-changed", { preventDefault: vi.fn() }, "in");
135+
window.webContents.emit("did-finish-load");
136+
vi.runAllTimers();
137+
138+
expect({
139+
zoomLevel: window.webContents.zoomLevel,
140+
saved: store.save.mock.calls,
141+
}).toEqual({
142+
zoomLevel: 1,
143+
saved: [[1]],
144+
});
145+
});
146+
147+
it("serializes wheel and menu zoom changes", () => {
148+
const window = createWindow();
149+
setupWindowZoom(window);
150+
151+
window.webContents.emit("zoom-changed", { preventDefault: vi.fn() }, "in");
109152
adjustWindowZoom(window, 0.5);
110153
vi.runAllTimers();
111154

112155
expect({
113156
zoomLevel: window.webContents.zoomLevel,
114157
saved: store.save.mock.calls,
115158
}).toEqual({
116-
zoomLevel: 2,
117-
saved: [[1.5], [2]],
159+
zoomLevel: 1.5,
160+
saved: [[1], [1.5]],
118161
});
119162
});
120163

121-
it("waits for native zoom before restoring after a reload", () => {
164+
it("uses the in-memory zoom level when persistence fails", () => {
122165
const window = createWindow();
123166
setupWindowZoom(window);
167+
store.save.mockImplementation(() => {});
124168

125-
window.webContents.emit("zoom-changed");
126-
window.webContents.zoomLevel = 1.5;
127-
window.webContents.emit("did-finish-load");
169+
window.webContents.emit("zoom-changed", { preventDefault: vi.fn() }, "in");
170+
vi.runAllTimers();
171+
window.webContents.emit("zoom-changed", { preventDefault: vi.fn() }, "in");
128172
vi.runAllTimers();
129173

130174
expect({
175+
persistedZoomLevel: store.state.zoomLevel,
131176
zoomLevel: window.webContents.zoomLevel,
132177
saved: store.save.mock.calls,
133178
}).toEqual({
179+
persistedZoomLevel: 0.5,
134180
zoomLevel: 1.5,
135-
saved: [[1.5]],
181+
saved: [[1], [1.5]],
136182
});
137183
});
138184

apps/code/src/main/zoom.ts

Lines changed: 35 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,14 @@ const ZOOM_MIN = -3;
66
const ZOOM_MAX = 3;
77

88
interface ZoomWebContents {
9-
getZoomLevel(): number;
10-
on(event: "did-finish-load" | "zoom-changed", listener: () => void): void;
9+
on(event: "did-finish-load", listener: () => void): void;
10+
on(
11+
event: "zoom-changed",
12+
listener: (
13+
event: { preventDefault(): void },
14+
zoomDirection: "in" | "out",
15+
) => void,
16+
): void;
1117
setZoomLevel(level: number): void;
1218
}
1319

@@ -25,8 +31,10 @@ interface ZoomWindow {
2531
}
2632

2733
interface ZoomState {
34+
currentZoomLevel: number;
2835
deferredActions: Array<() => void>;
29-
nativeZoomTimeout: ReturnType<typeof setTimeout> | null;
36+
wheelZoomDelta: number;
37+
wheelZoomTimeout: ReturnType<typeof setTimeout> | null;
3038
}
3139

3240
const zoomStates = new WeakMap<ZoomWindow, ZoomState>();
@@ -39,9 +47,13 @@ function getSavedZoomLevel(): number {
3947
return clampZoomLevel(windowStateStore.get("zoomLevel", 0));
4048
}
4149

42-
function runAfterNativeZoom(window: ZoomWindow, action: () => void): void {
50+
function getCurrentZoomLevel(window: ZoomWindow): number {
51+
return zoomStates.get(window)?.currentZoomLevel ?? getSavedZoomLevel();
52+
}
53+
54+
function runAfterWheelZoom(window: ZoomWindow, action: () => void): void {
4355
const state = zoomStates.get(window);
44-
if (!state?.nativeZoomTimeout) {
56+
if (!state?.wheelZoomTimeout) {
4557
action();
4658
return;
4759
}
@@ -51,6 +63,8 @@ function runAfterNativeZoom(window: ZoomWindow, action: () => void): void {
5163

5264
export function setWindowZoom(window: ZoomWindow, level: number): void {
5365
const nextLevel = clampZoomLevel(level);
66+
const state = zoomStates.get(window);
67+
if (state) state.currentZoomLevel = nextLevel;
5468
window.webContents.setZoomLevel(nextLevel);
5569
saveZoomLevel(nextLevel);
5670
}
@@ -59,22 +73,25 @@ export function adjustWindowZoom(
5973
window: ZoomWindow,
6074
delta: number | "reset",
6175
): void {
62-
runAfterNativeZoom(window, () => {
63-
const nextLevel = delta === "reset" ? 0 : getSavedZoomLevel() + delta;
76+
runAfterWheelZoom(window, () => {
77+
const nextLevel =
78+
delta === "reset" ? 0 : getCurrentZoomLevel(window) + delta;
6479
setWindowZoom(window, nextLevel);
6580
});
6681
}
6782

6883
export function restoreWindowZoom(window: ZoomWindow): void {
69-
runAfterNativeZoom(window, () => {
70-
window.webContents.setZoomLevel(getSavedZoomLevel());
84+
runAfterWheelZoom(window, () => {
85+
window.webContents.setZoomLevel(getCurrentZoomLevel(window));
7186
});
7287
}
7388

7489
export function setupWindowZoom(window: ZoomWindow): void {
7590
const state: ZoomState = {
91+
currentZoomLevel: getSavedZoomLevel(),
7692
deferredActions: [],
77-
nativeZoomTimeout: null,
93+
wheelZoomDelta: 0,
94+
wheelZoomTimeout: null,
7895
};
7996
let restoreTimeout: ReturnType<typeof setTimeout> | null = null;
8097
zoomStates.set(window, state);
@@ -88,11 +105,14 @@ export function setupWindowZoom(window: ZoomWindow): void {
88105
};
89106

90107
window.webContents.on("did-finish-load", () => restoreWindowZoom(window));
91-
window.webContents.on("zoom-changed", () => {
92-
if (state.nativeZoomTimeout) clearTimeout(state.nativeZoomTimeout);
93-
state.nativeZoomTimeout = setTimeout(() => {
94-
state.nativeZoomTimeout = null;
95-
saveZoomLevel(clampZoomLevel(window.webContents.getZoomLevel()));
108+
window.webContents.on("zoom-changed", (event, zoomDirection) => {
109+
event.preventDefault();
110+
state.wheelZoomDelta += zoomDirection === "in" ? ZOOM_STEP : -ZOOM_STEP;
111+
state.wheelZoomTimeout ??= setTimeout(() => {
112+
const nextLevel = state.currentZoomLevel + state.wheelZoomDelta;
113+
state.wheelZoomDelta = 0;
114+
state.wheelZoomTimeout = null;
115+
setWindowZoom(window, nextLevel);
96116
const deferredActions = state.deferredActions.splice(0);
97117
for (const action of deferredActions) action();
98118
}, 0);

packages/agent/src/handoff-checkpoint.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,7 @@ export class HandoffCheckpointTracker {
128128
!!capture.headPack && !uploads.pack?.storagePath;
129129
const indexUploadMissing = !uploads.index?.storagePath;
130130
if (packUploadMissing || indexUploadMissing) {
131-
this.logger.warn(
131+
this.logger.debug(
132132
"Discarding handoff checkpoint: required artifact uploads did not complete",
133133
{
134134
checkpointId: capture.checkpoint.checkpointId,
@@ -245,7 +245,7 @@ export class HandoffCheckpointTracker {
245245

246246
const content = await readFile(filePath);
247247
if (content.byteLength > MAX_ARTIFACT_UPLOAD_BYTES) {
248-
this.logger.warn(
248+
this.logger.debug(
249249
"Skipping handoff artifact upload: file exceeds the artifact size limit",
250250
{
251251
name,

packages/core/src/archive/archiveOrchestration.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ class Harness {
2929
stopCloudRun: vi.fn().mockResolvedValue(true),
3030
disconnectFromTask: vi.fn().mockResolvedValue(undefined),
3131
archive: vi.fn().mockResolvedValue(undefined),
32+
clearViewedState: vi.fn(),
3233
logError: vi.fn(),
3334
cache: {
3435
cancelPathFilter: vi.fn().mockResolvedValue(undefined),
@@ -59,10 +60,19 @@ describe("archiveTask", () => {
5960

6061
expect(harness.deps.archive).toHaveBeenCalledWith(TASK_ID);
6162
expect(harness.deps.disconnectFromTask).toHaveBeenCalledWith(TASK_ID);
63+
expect(harness.deps.clearViewedState).toHaveBeenCalledWith(TASK_ID);
6264
expect(harness.ids).toContain(TASK_ID);
6365
expect(harness.list.some((a) => a.taskId === TASK_ID)).toBe(true);
6466
});
6567

68+
it("does not clear read state when the archive request fails", async () => {
69+
harness.deps.archive = vi.fn().mockRejectedValue(new Error("boom"));
70+
71+
await expect(archiveTask(TASK_ID, harness.deps)).rejects.toThrow("boom");
72+
73+
expect(harness.deps.clearViewedState).not.toHaveBeenCalled();
74+
});
75+
6676
it("with optimistic:false, defers cache writes until archive resolves", async () => {
6777
let idsWhenArchiveCalled: string[] = ["sentinel"];
6878
harness.deps.archive = vi.fn().mockImplementation(async () => {

packages/core/src/archive/archiveOrchestration.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ export interface ArchiveOrchestrationDeps {
3939
stopCloudRun(taskId: string, runId?: string): Promise<boolean>;
4040
disconnectFromTask(taskId: string): Promise<void>;
4141
archive(taskId: string): Promise<void>;
42+
clearViewedState(taskId: string): void;
4243
logError(message: string, error: unknown): void;
4344
cache: ArchiveCacheWriter;
4445
}
@@ -103,9 +104,8 @@ export async function archiveTask(
103104
try {
104105
await deps.disconnectFromTask(taskId);
105106
await deps.archive(taskId);
106-
// Destroying terminals is irreversible, so it waits for the archive to
107-
// commit; a failed archive keeps its live terminals.
108107
deps.clearTerminalStates(taskId);
108+
deps.clearViewedState(taskId);
109109
// Non-optimistic flows keep the row visible during the request, then remove
110110
// it the moment the archive succeeds.
111111
if (!optimistic) {

0 commit comments

Comments
 (0)