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
2 changes: 2 additions & 0 deletions apps/desktop/src/ipc/DesktopIpcHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import {
getAppBranding,
getLocalEnvironmentBootstraps,
getLocalEnvironmentBearerToken,
getWindowFullscreenState,
isNotificationSupported,
openExternal,
pickFolder,
Expand All @@ -58,6 +59,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers"
yield* PreviewIpc.installPreviewEventForwarding();

yield* ipc.handleSync(getAppBranding);
yield* ipc.handleSync(getWindowFullscreenState);
yield* ipc.handleSync(getLocalEnvironmentBootstraps);
yield* ipc.handle(getLocalEnvironmentBearerToken);

Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/ipc/channels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ export const DRAIN_NOTIFICATION_ACTIONS_CHANNEL = "desktop:drain-notification-ac
export const ACK_NOTIFICATION_ACTIONS_CHANNEL = "desktop:ack-notification-actions";
export const NOTIFICATION_ACTION_CHANNEL = "desktop:notification-action";
export const MENU_ACTION_CHANNEL = "desktop:menu-action";
export const GET_WINDOW_FULLSCREEN_STATE_CHANNEL = "desktop:get-window-fullscreen-state";
export const WINDOW_FULLSCREEN_STATE_CHANNEL = "desktop:window-fullscreen-state";
export const UPDATE_STATE_CHANNEL = "desktop:update-state";
export const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state";
export const UPDATE_SET_CHANNEL_CHANNEL = "desktop:update-set-channel";
Expand Down
22 changes: 21 additions & 1 deletion apps/desktop/src/ipc/methods/window.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
import { assert, describe, it } from "@effect/vitest";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";

import type * as Electron from "electron";

import * as DesktopBackendManager from "../../backend/DesktopBackendManager.ts";
import * as DesktopBackendPool from "../../backend/DesktopBackendPool.ts";
import { getLocalEnvironmentBootstraps } from "./window.ts";
import * as ElectronWindow from "../../electron/ElectronWindow.ts";
import { getLocalEnvironmentBootstraps, getWindowFullscreenState } from "./window.ts";

const readyWslConfig: DesktopBackendManager.DesktopBackendStartConfig = {
executablePath: "wsl.exe",
Expand Down Expand Up @@ -126,3 +130,19 @@ describe("getLocalEnvironmentBootstraps", () => {
}).pipe(Effect.provide(DesktopBackendPool.layerTest([stoppedInstance])));
});
});

describe("getWindowFullscreenState", () => {
it.effect("reads the current native window state", () => {
const window = { isFullScreen: () => true } as Electron.BrowserWindow;

return Effect.gen(function* () {
assert.isTrue(yield* getWindowFullscreenState.handler());
}).pipe(
Effect.provide(
Layer.mock(ElectronWindow.ElectronWindow)({
currentMainOrFirst: Effect.succeed(Option.some(window)),
}),
),
);
});
});
10 changes: 10 additions & 0 deletions apps/desktop/src/ipc/methods/window.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,16 @@ export const getAppBranding = DesktopIpc.makeSyncIpcMethod({
}),
});

export const getWindowFullscreenState = DesktopIpc.makeSyncIpcMethod({
channel: IpcChannels.GET_WINDOW_FULLSCREEN_STATE_CHANNEL,
result: Schema.Boolean,
handler: Effect.fn("desktop.ipc.window.getWindowFullscreenState")(function* () {
const electronWindow = yield* ElectronWindow.ElectronWindow;
const window = yield* electronWindow.currentMainOrFirst;
return Option.isSome(window) && window.value.isFullScreen();
}),
});

export const getLocalEnvironmentBootstraps = DesktopIpc.makeSyncIpcMethod({
channel: IpcChannels.GET_LOCAL_ENVIRONMENT_BOOTSTRAPS_CHANNEL,
result: Schema.Array(DesktopEnvironmentBootstrapSchema),
Expand Down
13 changes: 13 additions & 0 deletions apps/desktop/src/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,19 @@ contextBridge.exposeInMainWorld("desktopBridge", {
ipcRenderer.removeListener(IpcChannels.MENU_ACTION_CHANNEL, wrappedListener);
};
},
getWindowFullscreenState: () =>
ipcRenderer.sendSync(IpcChannels.GET_WINDOW_FULLSCREEN_STATE_CHANNEL) === true,
onWindowFullscreenStateChange: (listener) => {
const wrappedListener = (_event: Electron.IpcRendererEvent, fullscreen: unknown) => {
if (typeof fullscreen !== "boolean") return;
listener(fullscreen);
};

ipcRenderer.on(IpcChannels.WINDOW_FULLSCREEN_STATE_CHANNEL, wrappedListener);
return () => {
ipcRenderer.removeListener(IpcChannels.WINDOW_FULLSCREEN_STATE_CHANNEL, wrappedListener);
};
},
getUpdateState: () => ipcRenderer.invoke(IpcChannels.UPDATE_GET_STATE_CHANNEL),
setUpdateChannel: (channel) =>
ipcRenderer.invoke(IpcChannels.UPDATE_SET_CHANNEL_CHANNEL, channel),
Expand Down
40 changes: 38 additions & 2 deletions apps/desktop/src/window/DesktopWindow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ import * as ElectronMenu from "../electron/ElectronMenu.ts";
import * as ElectronShell from "../electron/ElectronShell.ts";
import * as ElectronTheme from "../electron/ElectronTheme.ts";
import * as ElectronWindow from "../electron/ElectronWindow.ts";
import { MENU_ACTION_CHANNEL } from "../ipc/channels.ts";
import { MENU_ACTION_CHANNEL, WINDOW_FULLSCREEN_STATE_CHANNEL } from "../ipc/channels.ts";
import * as DesktopServerExposure from "../backend/DesktopServerExposure.ts";
import * as DesktopWindow from "./DesktopWindow.ts";
import * as PreviewManager from "../preview/Manager.ts";
Expand All @@ -46,6 +46,7 @@ const environmentInput = {
} satisfies DesktopEnvironment.MakeDesktopEnvironmentInput;

function makeFakeBrowserWindow() {
const windowListeners = new Map<string, (...args: readonly unknown[]) => void>();
const webContentsListeners = new Map<string, (...args: readonly unknown[]) => void>();
const addWebContentsListener = (
eventName: string,
Expand Down Expand Up @@ -75,13 +76,16 @@ function makeFakeBrowserWindow() {
close: vi.fn(),
focus: vi.fn(),
isDestroyed: vi.fn(() => false),
isFullScreen: vi.fn(() => false),
isMinimized: vi.fn(() => false),
isVisible: vi.fn(() => true),
loadURL: vi.fn((url: string) => {
currentUrl = url;
return Promise.resolve();
}),
on: vi.fn(),
on: vi.fn((eventName: string, listener: (...args: readonly unknown[]) => void) => {
windowListeners.set(eventName, listener);
}),
once: vi.fn(),
restore: vi.fn(),
setBackgroundColor: vi.fn(),
Expand All @@ -100,6 +104,7 @@ function makeFakeBrowserWindow() {
send: webContents.send,
setAutoHideCursor: window.setAutoHideCursor,
webContentsListeners,
windowListeners,
};
}

Expand Down Expand Up @@ -403,6 +408,37 @@ describe("DesktopWindow", () => {
}),
);

it.effect("publishes native macOS fullscreen changes to the renderer", () =>
Effect.gen(function* () {
const fakeWindow = makeFakeBrowserWindow();
const createCount = yield* Ref.make(0);
const mainWindow = yield* Ref.make<Option.Option<Electron.BrowserWindow>>(Option.none());
const layer = makeTestLayer({
window: fakeWindow.window,
createCount,
mainWindow,
});

yield* Effect.gen(function* () {
const desktopWindow = yield* DesktopWindow.DesktopWindow;
yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773"));

const enterFullscreen = fakeWindow.windowListeners.get("enter-full-screen");
const leaveFullscreen = fakeWindow.windowListeners.get("leave-full-screen");
if (!enterFullscreen || !leaveFullscreen) {
return yield* Effect.die("fullscreen listeners were not registered");
}

enterFullscreen();
leaveFullscreen();
assert.deepEqual(fakeWindow.send.mock.calls, [
[WINDOW_FULLSCREEN_STATE_CHANNEL, true],
[WINDOW_FULLSCREEN_STATE_CHANNEL, false],
]);
}).pipe(Effect.provide(layer));
}),
);

it.effect("recovers when the development renderer is temporarily unreachable", () =>
Effect.gen(function* () {
const fakeWindow = makeFakeBrowserWindow();
Expand Down
11 changes: 10 additions & 1 deletion apps/desktop/src/window/DesktopWindow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import { getDesktopUrl } from "../electron/ElectronProtocol.ts";
import * as ElectronShell from "../electron/ElectronShell.ts";
import * as ElectronTheme from "../electron/ElectronTheme.ts";
import * as ElectronWindow from "../electron/ElectronWindow.ts";
import { MENU_ACTION_CHANNEL } from "../ipc/channels.ts";
import { MENU_ACTION_CHANNEL, WINDOW_FULLSCREEN_STATE_CHANNEL } from "../ipc/channels.ts";
import * as PreviewManager from "../preview/Manager.ts";

const TITLEBAR_HEIGHT = 40;
Expand Down Expand Up @@ -497,6 +497,15 @@ export const make = Effect.gen(function* () {
window.setTitle(environment.displayName);
});

if (environment.platform === "darwin") {
window.on("enter-full-screen", () => {
window.webContents.send(WINDOW_FULLSCREEN_STATE_CHANNEL, true);
});
window.on("leave-full-screen", () => {
window.webContents.send(WINDOW_FULLSCREEN_STATE_CHANNEL, false);
});
}

let developmentLoadRetryIndex = 0;
let developmentLoadRetryFiber: Fiber.Fiber<void, never> | undefined;
const clearDevelopmentLoadRetry = () => {
Expand Down
4 changes: 2 additions & 2 deletions apps/mobile/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,8 @@ vp run ios:dev
```

If your Xcode account only has a Personal Team, use a bundle identifier you control and opt into the
reduced-capability local build. Personal Team builds omit the widget extension, push entitlement, and
native Sign in with Apple entitlement; builds without this opt-in are unchanged.
reduced-capability local build. Personal Team builds omit the widget and share extensions, push
entitlement, and native Sign in with Apple entitlement; builds without this opt-in are unchanged.

```bash
T3CODE_IOS_PERSONAL_TEAM=1 \
Expand Down
36 changes: 33 additions & 3 deletions apps/mobile/app.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,9 @@ function resolveAppVariant(value: string | undefined): AppVariant {
}

const variant = VARIANT_CONFIG[APP_VARIANT];
const iosBundleIdentifier = isIosPersonalTeamBuild
? personalTeamBundleIdentifier!
: variant.iosBundleIdentifier;
const firstNonEmpty = (...values: ReadonlyArray<string | undefined>): string | undefined =>
values.find((value) => value !== undefined && value.trim().length > 0);
const easProjectId =
Expand Down Expand Up @@ -134,8 +137,8 @@ const dmSansFonts = {
const widgetsPlugin: NonNullable<ExpoConfig["plugins"]>[number] = [
"expo-widgets",
{
bundleIdentifier: `${variant.iosBundleIdentifier}.widgets`,
groupIdentifier: `group.${variant.iosBundleIdentifier}`,
bundleIdentifier: `${iosBundleIdentifier}.widgets`,
groupIdentifier: `group.${iosBundleIdentifier}`,
enablePushNotifications: true,
// Agent activity can update many times an hour; without the
// frequent-updates entitlement iOS throttles the update budget sooner.
Expand All @@ -151,6 +154,30 @@ const widgetsPlugin: NonNullable<ExpoConfig["plugins"]>[number] = [
},
];

const sharingPlugin: NonNullable<ExpoConfig["plugins"]>[number] = [
"expo-sharing",
{
ios: {
// Personal Teams cannot sign App Groups or extension targets. Keep the
// reduced-capability local build usable while release builds expose the
// real system share target.
enabled: !isIosPersonalTeamBuild,
extensionBundleIdentifier: `${iosBundleIdentifier}.sharing`,
appGroupId: `group.${iosBundleIdentifier}`,
activationRule: {
supportsText: true,
supportsWebUrlWithMaxCount: 1,
supportsImageWithMaxCount: 8,
},
},
android: {
enabled: true,
singleShareMimeTypes: ["text/plain", "image/*"],
multipleShareMimeTypes: ["image/*"],
},
},
];

// These aliases match the fonts' PostScript names on iOS. Register the same
// names on Android so React Native and the native composer use one set of
// family names without waiting for runtime font loading.
Expand Down Expand Up @@ -180,7 +207,7 @@ const config: ExpoConfig = {
ios: {
icon: variant.assets.iosIcon,
supportsTablet: true,
bundleIdentifier: variant.iosBundleIdentifier,
bundleIdentifier: iosBundleIdentifier,
// Pin code signing to the T3 Tools team so non-interactive `expo run:ios`
// does not fall back to a personal team (which cannot sign app groups,
// Sign in with Apple, or push notification entitlements).
Expand Down Expand Up @@ -242,6 +269,9 @@ const config: ExpoConfig = {
],
"expo-secure-store",
"expo-sqlite",
...(isIosPersonalTeamBuild
? [sharingPlugin]
: ["./plugins/withShareExtensionDisplayName.cjs", sharingPlugin]),
[
"expo-notifications",
{
Expand Down
2 changes: 1 addition & 1 deletion apps/mobile/modules/t3-terminal/ios/T3TerminalModule.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ public class T3TerminalModule: Module {
// Bumped when native hardware-keyboard handling changes; surfaced in the JS debug
// logs so a stale native binary is distinguishable from a broken key pipeline.
Constants([
"hardwareKeyRevision": 2,
"hardwareKeyRevision": 3,
])

View(T3TerminalView.self) {
Expand Down
21 changes: 19 additions & 2 deletions apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,21 @@ private enum TerminalHardwareKeyEncoder {
}
}

private enum TerminalInputSequence {
/// Terminal Enter is carriage return. Sending line feed instead is Ctrl+J,
/// which raw-mode TUIs may interpret as the literal J key.
static let carriageReturn = "\r"

static func normalizingReturn(_ input: String) -> String {
switch input {
case "\n", "\r\n":
return carriageReturn
default:
return input
}
}
}

private final class TerminalInputField: UITextField {
var onDeleteBackward: (() -> Void)?
var onInsert: ((String) -> Void)?
Expand Down Expand Up @@ -352,15 +367,17 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate {

public func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool {
if !string.isEmpty {
emitInput(string)
// Some software keyboards deliver Return through this delegate instead of
// textFieldShouldReturn, so normalize that path too.
emitInput(TerminalInputSequence.normalizingReturn(string))
return false
}

return false
}

public func textFieldShouldReturn(_ textField: UITextField) -> Bool {
emitInput("\n")
emitInput(TerminalInputSequence.carriageReturn)
textField.text = ""
return false
}
Expand Down
1 change: 1 addition & 0 deletions apps/mobile/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@
"expo-paste-input": "^0.1.15",
"expo-quick-actions": "^6.0.2",
"expo-secure-store": "~56.0.4",
"expo-sharing": "~56.0.18",
"expo-splash-screen": "~56.0.10",
"expo-sqlite": "~56.0.5",
"expo-symbols": "~56.0.6",
Expand Down
Loading
Loading