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
44 changes: 44 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,50 @@ Target-specific build commands are also available:

---

## Linux: Hyprland / Omarchy

Recordly's recording HUD, countdown, and source picker are transparent floating windows.
Hyprland decorates them like any other window, so the compositor's blur, shadow, dim, and
opacity rules show up as a grey box around the HUD. Wayland also ignores `alwaysOnTop`, so
the HUD can end up behind other windows or stuck on one workspace.

Add these rules to `~/.config/hypr/hyprland.lua` (Omarchy) and run `hyprctl reload`:

```lua
o.window("^[Rr]ecordly$", { tag = "-default-opacity", opacity = "1 1" })
o.window({ class = "^[Rr]ecordly$", float = true }, {
pin = true,
no_blur = true,
no_shadow = true,
border_size = 0,
no_dim = true,
})
```

Plain `hyprland.conf` equivalents:

**Hyprland 0.53.0+ (new windowrule syntax):**

```ini
windowrule = opacity 1 1, match:class ^[Rr]ecordly$
windowrule = pin, match:class ^[Rr]ecordly$, match:float 1
windowrule = noblur, match:class ^[Rr]ecordly$, match:float 1
windowrule = noshadow, match:class ^[Rr]ecordly$, match:float 1
windowrule = nodim, match:class ^[Rr]ecordly$, match:float 1
windowrule = bordersize 0, match:class ^[Rr]ecordly$, match:float 1
```

**Hyprland < 0.53.0 (legacy windowrulev2 syntax):**

```ini
windowrulev2 = opacity 1 1, class:^[Rr]ecordly$
windowrulev2 = pin, class:^[Rr]ecordly$, floating:1
windowrulev2 = noblur, class:^[Rr]ecordly$, floating:1
windowrulev2 = noshadow, class:^[Rr]ecordly$, floating:1
windowrulev2 = nodim, class:^[Rr]ecordly$, floating:1
windowrulev2 = bordersize 0, class:^[Rr]ecordly$, floating:1
```

## macOS: "App cannot be opened"

Locally built apps may be quarantined by macOS.
Expand Down
2 changes: 2 additions & 0 deletions electron/electron-env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,7 @@ interface RendererNativeExportCapabilities {
interface Window {
electronAPI: {
hudOverlaySetIgnoreMouse: (ignore: boolean) => void;
hudOverlaySetMenuOpen: (open: boolean) => void;
hudOverlaySetSourceSelectionActive: (active: boolean) => void;
hudOverlayDrag: (phase: "start" | "move" | "end", screenX: number, screenY: number) => void;
hudOverlayHide: () => void;
Expand All @@ -208,6 +209,7 @@ interface Window {
getHudOverlayMousePassthroughSupported: () => Promise<{
success: boolean;
supported: boolean;
resizeAnchor?: "bottom" | "center";
}>;
setHudOverlayCaptureProtection: (
enabled: boolean,
Expand Down
39 changes: 31 additions & 8 deletions electron/hudOverlayBounds.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";

import {
getHudOverlayResizeAnchor,
getHudOverlayWindowBounds,
resizeHudOverlayFallbackBounds,
shouldExpandHudOverlayFallback,
Expand Down Expand Up @@ -30,9 +31,9 @@ describe("getHudOverlayWindowBounds", () => {
it("expands the non-passthrough fallback for HUD menus and hover interaction", () => {
expect(getHudOverlayWindowBounds(workArea, false, true)).toEqual({
x: 650,
y: 540,
y: 400,
width: 860,
height: 540,
height: 680,
});
});

Expand Down Expand Up @@ -98,9 +99,9 @@ describe("resizeHudOverlayFallbackBounds", () => {
),
).toEqual({
x: 420,
y: 320,
y: 180,
width: 860,
height: 540,
height: 680,
});
});

Expand All @@ -110,9 +111,9 @@ describe("resizeHudOverlayFallbackBounds", () => {
workArea,
{
x: 420,
y: 320,
y: 180,
width: 860,
height: 540,
height: 680,
},
false,
),
Expand All @@ -138,13 +139,35 @@ describe("resizeHudOverlayFallbackBounds", () => {
),
).toEqual({
x: 1060,
y: 520,
y: 380,
width: 860,
height: 540,
height: 680,
});
});
});

describe("getHudOverlayResizeAnchor", () => {
it("returns bottom on non-linux platforms", () => {
expect(getHudOverlayResizeAnchor("darwin", {})).toBe("bottom");
expect(getHudOverlayResizeAnchor("win32", {})).toBe("bottom");
});

it("returns center on linux under wayland", () => {
expect(
getHudOverlayResizeAnchor("linux", { XDG_SESSION_TYPE: "wayland" }),
).toBe("center");
expect(
getHudOverlayResizeAnchor("linux", { WAYLAND_DISPLAY: "wayland-1" }),
).toBe("center");
});

it("returns bottom on linux under x11", () => {
expect(
getHudOverlayResizeAnchor("linux", { XDG_SESSION_TYPE: "x11" }),
).toBe("bottom");
});
});

describe("shouldExpandHudOverlayFallback", () => {
it("expands while recording only when the floating webcam preview is visible", () => {
expect(
Expand Down
17 changes: 16 additions & 1 deletion electron/hudOverlayBounds.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,27 @@ export interface HudOverlayWorkArea {

const NON_PASSTHROUGH_HUD_WIDTH_DIP = 860;
const NON_PASSTHROUGH_HUD_COMPACT_HEIGHT_DIP = 160;
const NON_PASSTHROUGH_HUD_EXPANDED_HEIGHT_DIP = 540;
const NON_PASSTHROUGH_HUD_EXPANDED_HEIGHT_DIP = 680;

function clamp(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max);
}

export type HudOverlayResizeAnchor = "bottom" | "center";

// Wayland refuses client-side window placement, so the compositor decides where
// a resized HUD lands. Hyprland keeps floating windows centered while X11 honors
// the bottom-anchored bounds this module computes.
export function getHudOverlayResizeAnchor(
platform: NodeJS.Platform,
env: NodeJS.ProcessEnv,
): HudOverlayResizeAnchor {
if (platform !== "linux") {
return "bottom";
}
return env.XDG_SESSION_TYPE === "wayland" || env.WAYLAND_DISPLAY ? "center" : "bottom";
}

export function getHudOverlayWindowBounds(
workArea: HudOverlayWorkArea,
mousePassthroughSupported: boolean,
Expand Down
3 changes: 3 additions & 0 deletions electron/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,9 @@ contextBridge.exposeInMainWorld("electronAPI", {
hudOverlaySetIgnoreMouse: (ignore: boolean) => {
ipcRenderer.send("hud-overlay-set-ignore-mouse", ignore);
},
hudOverlaySetMenuOpen: (open: boolean) => {
ipcRenderer.send("hud-overlay-set-menu-open", open);
},
hudOverlaySetSourceSelectionActive: (active: boolean) => {
ipcRenderer.send("hud-overlay-set-source-selection-active", active);
},
Expand Down
10 changes: 10 additions & 0 deletions electron/windows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { app, BrowserWindow, ipcMain } from "electron";
import { supportsHudCaptureProtection } from "../src/lib/hudCaptureProtection";
import { USER_DATA_PATH } from "./appPaths";
import {
getHudOverlayResizeAnchor,
getHudOverlayWindowBounds,
resizeHudOverlayFallbackBounds,
shouldExpandHudOverlayFallback,
Expand Down Expand Up @@ -331,6 +332,14 @@ ipcMain.on("hud-overlay-set-ignore-mouse", (_event, ignore: boolean) => {
setHudOverlayMousePassthrough(Boolean(ignore));
});

// Linux has no hover-driven passthrough, so the compact HUD window only grows
// while a menu is open; growing on hover moves the bar out from under the pointer.
ipcMain.on("hud-overlay-set-menu-open", (_event, open: boolean) => {
if (process.platform === "linux") {
setHudOverlayFallbackExpanded(Boolean(open));
}
});

ipcMain.on("hud-overlay-set-source-selection-active", (_event, active: boolean) => {
hudOverlaySourceSelectionActive = Boolean(active);
if (hudOverlaySourceSelectionActive) {
Expand Down Expand Up @@ -418,6 +427,7 @@ ipcMain.handle("get-hud-overlay-mouse-passthrough-supported", () => {
return {
success: true,
supported: isHudOverlayMousePassthroughSupported(),
resizeAnchor: getHudOverlayResizeAnchor(process.platform, process.env),
};
});

Expand Down
24 changes: 22 additions & 2 deletions src/components/launch/LaunchWindow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,19 @@ import { MarqueeText } from "./SourceSelector";

const SHOW_DEV_UPDATE_PREVIEW = import.meta.env.DEV;

// Wayland compositors (e.g. Hyprland) re-center resizing floating windows around their
// midpoint rather than honoring bottom bounds.
// In compact mode, the window is 160px tall with 20px (1.25rem / pb-5) bottom padding.
// The distance from the window center (50vh = 80px) to the HUD bottom edge is:
// 80px - 20px = 60px.
// Setting paddingBottom to calc(50vh - 60px) maintains this constant 60px offset from
// the window center, ensuring the HUD bar remains visually stationary on screen when
// the window expands vertically to accommodate popover menus.
const COMPACT_HUD_HEIGHT_DIP = 160;
const STANDARD_HUD_BOTTOM_PADDING_PX = 20; // 1.25rem (pb-5)
const WAYLAND_CENTER_OFFSET_PX =
COMPACT_HUD_HEIGHT_DIP / 2 - STANDARD_HUD_BOTTOM_PADDING_PX;

export function LaunchWindow() {
return (
<LaunchPopoverCoordinatorProvider>
Expand Down Expand Up @@ -109,6 +122,7 @@ function LaunchWindowContent() {

const {
hudOverlayMousePassthroughSupported,
hudOverlayResizeAnchor,
platform,
appVersion,
hideHudFromCapture,
Expand Down Expand Up @@ -446,8 +460,14 @@ function LaunchWindowContent() {
value={{ onMouseEnter: handleHudMouseEnter, onMouseLeave: handleHudMouseLeave }}
>
<div
className="w-full flex justify-center bg-transparent overflow-visible items-end pb-5 pointer-events-none"
style={{ height: "100vh" }}
className="w-full flex justify-center bg-transparent overflow-visible items-end pointer-events-none"
style={{
height: "100vh",
paddingBottom:
hudOverlayResizeAnchor === "center"
? `calc(50vh - ${WAYLAND_CENTER_OFFSET_PX}px)`
: "1.25rem",
}}
Comment on lines +464 to +470

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in latest commit: derived WAYLAND_CENTER_OFFSET_PX (COMPACT_HUD_HEIGHT_DIP / 2 - STANDARD_HUD_BOTTOM_PADDING_PX = 60px) and documented the Wayland center-anchored geometry.

>
<div
ref={hudContentRef}
Expand Down
25 changes: 22 additions & 3 deletions src/components/launch/hooks/useLaunchHudInteractionState.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { type MouseEvent, type RefObject, useCallback, useEffect, useRef } from "react";
import { type MouseEvent, type RefObject, useCallback, useEffect, useLayoutEffect, useRef } from "react";

export function useLaunchHudInteractionState({
openId,
Expand All @@ -13,19 +13,38 @@ export function useLaunchHudInteractionState({
}) {
const isMouseOverHudRef = useRef(false);
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const popoverCloseTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const openIdRef = useRef(openId);

useLayoutEffect(() => {
openIdRef.current = openId;
}, [openId]);

useEffect(() => {
if (popoverCloseTimeoutRef.current) {
clearTimeout(popoverCloseTimeoutRef.current);
popoverCloseTimeoutRef.current = null;
}

window.electronAPI?.hudOverlaySetMenuOpen?.(openId !== null);
if (openId !== null) {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
window.electronAPI?.hudOverlaySetIgnoreMouse?.(false);
Comment on lines 23 to 32

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in latest commit: tracked popoverCloseTimeoutRef to clear active timeouts when openId changes or unmounts, and gated the callback to check openIdRef.current === null.

} else {
// Proactively check if we should ignore mouse when popover closes
setTimeout(() => {
if (!isMouseOverHudRef.current) {
popoverCloseTimeoutRef.current = setTimeout(() => {
if (openIdRef.current === null && !isMouseOverHudRef.current) {
window.electronAPI?.hudOverlaySetIgnoreMouse?.(true);
}
}, 150);
}

return () => {
if (popoverCloseTimeoutRef.current) {
clearTimeout(popoverCloseTimeoutRef.current);
popoverCloseTimeoutRef.current = null;
}
};
}, [openId]);

useEffect(() => {
Expand Down
5 changes: 5 additions & 0 deletions src/components/launch/hooks/useLaunchWindowSystemState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ export function useLaunchWindowSystemState(
const [hudOverlayMousePassthroughSupported, setHudOverlayMousePassthroughSupported] = useState<
boolean | null
>(null);
const [hudOverlayResizeAnchor, setHudOverlayResizeAnchor] = useState<"bottom" | "center">(
"bottom",
);
const [platform, setPlatform] = useState<string | null>(null);
const [appVersion, setAppVersion] = useState<string | null>(null);
const [hideHudFromCapture, setHideHudFromCapture] = useState(true);
Expand Down Expand Up @@ -54,6 +57,7 @@ export function useLaunchWindowSystemState(
const result = await window.electronAPI.getHudOverlayMousePassthroughSupported();
if (!cancelled && result.success) {
setHudOverlayMousePassthroughSupported(result.supported);
setHudOverlayResizeAnchor(result.resizeAnchor ?? "bottom");
}
} catch (error) {
console.error("Failed to load HUD overlay mouse passthrough support:", error);
Expand Down Expand Up @@ -132,6 +136,7 @@ export function useLaunchWindowSystemState(
return {
recordingsDirectory,
hudOverlayMousePassthroughSupported,
hudOverlayResizeAnchor,
platform,
appVersion,
hideHudFromCapture,
Expand Down