Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a0b9f1e19b | ||
|
|
a3bdf6f4da | ||
|
|
dc9cdd1760 | ||
|
|
c16f0d5b70 |
@@ -321,6 +321,13 @@ jobs:
|
|||||||
export PATH="$HOME/.cargo/bin:$PATH"
|
export PATH="$HOME/.cargo/bin:$PATH"
|
||||||
npx tauri build
|
npx tauri build
|
||||||
|
|
||||||
|
# linuxdeploy bundles a libwayland-client.so.0 that shadows the host's
|
||||||
|
# and breaks Mesa's EGL on systems newer than the build runner, so the
|
||||||
|
# window comes up blank. It has to come from the host; see the script
|
||||||
|
# header for the evidence and the trade.
|
||||||
|
- name: Unbundle the host-coupled Wayland client
|
||||||
|
run: bash scripts/unbundle-wayland-client.sh app/src-tauri/target/release/bundle/appimage
|
||||||
|
|
||||||
- name: Collect artifacts
|
- name: Collect artifacts
|
||||||
run: |
|
run: |
|
||||||
mkdir -p artifacts
|
mkdir -p artifacts
|
||||||
|
|||||||
@@ -187,6 +187,13 @@ jobs:
|
|||||||
export PATH="$HOME/.cargo/bin:$PATH"
|
export PATH="$HOME/.cargo/bin:$PATH"
|
||||||
npx tauri build
|
npx tauri build
|
||||||
|
|
||||||
|
# linuxdeploy bundles a libwayland-client.so.0 that shadows the host's
|
||||||
|
# and breaks Mesa's EGL on systems newer than the build runner, so the
|
||||||
|
# window comes up blank. It has to come from the host; see the script
|
||||||
|
# header for the evidence and the trade.
|
||||||
|
- name: Unbundle the host-coupled Wayland client
|
||||||
|
run: bash scripts/unbundle-wayland-client.sh app/src-tauri/target/release/bundle/appimage
|
||||||
|
|
||||||
- name: Collect artifacts
|
- name: Collect artifacts
|
||||||
run: |
|
run: |
|
||||||
mkdir -p artifacts
|
mkdir -p artifacts
|
||||||
|
|||||||
@@ -3,10 +3,19 @@
|
|||||||
|
|
||||||
/// WebKitGTK's DMA-BUF renderer (its default accelerated-compositing path
|
/// WebKitGTK's DMA-BUF renderer (its default accelerated-compositing path
|
||||||
/// since 2.42) fails outright on some Mesa/driver/compositor combinations
|
/// since 2.42) fails outright on some Mesa/driver/compositor combinations
|
||||||
/// under Wayland, printing `Could not create default EGL display:
|
/// under Wayland, killing the webview and leaving a blank window — see
|
||||||
/// EGL_BAD_PARAMETER. Aborting.` straight to stderr from WebKitGTK's own C
|
/// triple-c#34, reported on CachyOS/Arch with Wayland.
|
||||||
/// code and killing the webview before Triple-C's own logging even starts —
|
///
|
||||||
/// see triple-c#34, reported on CachyOS/Arch with Wayland.
|
/// **This is not the only cause of a blank window, and the error text alone
|
||||||
|
/// does not tell them apart.** An earlier version of this comment quoted
|
||||||
|
/// `Could not create default EGL display: EGL_BAD_PARAMETER. Aborting.` as
|
||||||
|
/// the error this fixes. The AppImage produces that same string for an
|
||||||
|
/// entirely unrelated reason: it bundled a `libwayland-client.so.0` that
|
||||||
|
/// shadowed the host's, and the host's `libEGL_mesa.so.0` has a hard
|
||||||
|
/// DT_NEEDED on that library, so the EGL driver failed to load before any
|
||||||
|
/// renderer choice was reachable. This flag was set, and correctly, and made no difference —
|
||||||
|
/// which cost a round of debugging that started from the comment rather than
|
||||||
|
/// from the evidence. See `scripts/unbundle-wayland-client.sh`.
|
||||||
///
|
///
|
||||||
/// Set unconditionally on Linux rather than gated on `WAYLAND_DISPLAY`: that
|
/// Set unconditionally on Linux rather than gated on `WAYLAND_DISPLAY`: that
|
||||||
/// variable is exported into an XWayland client's environment too, so a
|
/// variable is exported into an XWayland client's environment too, so a
|
||||||
|
|||||||
@@ -11,14 +11,22 @@ vi.mock("../../hooks/useTerminal", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
const setActiveTabKey = vi.fn();
|
const setActiveTabKey = vi.fn();
|
||||||
|
const requestTerminalFocus = vi.fn();
|
||||||
const pushToast = vi.fn();
|
const pushToast = vi.fn();
|
||||||
let projects: Project[] = [];
|
let projects: Project[] = [];
|
||||||
|
|
||||||
vi.mock("../../store/appState", () => ({
|
vi.mock("../../store/appState", () => ({
|
||||||
useAppState: Object.assign(
|
useAppState: Object.assign(
|
||||||
(selector: (s: unknown) => unknown) =>
|
(selector: (s: unknown) => unknown) =>
|
||||||
selector({ projects, setActiveTabKey, pushToast }),
|
selector({ projects, setActiveTabKey, requestTerminalFocus, pushToast }),
|
||||||
{ getState: () => ({ projects, setActiveTabKey, pushToast }) },
|
{
|
||||||
|
getState: () => ({
|
||||||
|
projects,
|
||||||
|
setActiveTabKey,
|
||||||
|
requestTerminalFocus,
|
||||||
|
pushToast,
|
||||||
|
}),
|
||||||
|
},
|
||||||
),
|
),
|
||||||
terminalTabKey: (id: string) => `term:${id}`,
|
terminalTabKey: (id: string) => `term:${id}`,
|
||||||
}));
|
}));
|
||||||
@@ -149,4 +157,35 @@ describe("SendToAgentButton", () => {
|
|||||||
// overflow, so a downward menu at the bottom edge is invisible.
|
// overflow, so a downward menu at the bottom edge is invisible.
|
||||||
await waitFor(() => expect(screen.getByRole("menu")).toHaveClass("bottom-full"));
|
await waitFor(() => expect(screen.getByRole("menu")).toHaveClass("bottom-full"));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Switching to the tab is not enough. When the dock is open beside the
|
||||||
|
// terminal it sends to, that terminal is already the active tab, so
|
||||||
|
// `setActiveTabKey` changes nothing and no effect re-runs — leaving focus on
|
||||||
|
// this button, one click short of the Enter the user came to press.
|
||||||
|
it("hands focus to the terminal so the next keystroke is Enter", async () => {
|
||||||
|
sessions = [session()];
|
||||||
|
render(<SendToAgentButton projectId="p1" body="hello" />);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /send to agent/i }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(requestTerminalFocus).toHaveBeenCalledWith("s1"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves focus alone when the send failed", async () => {
|
||||||
|
sessions = [session()];
|
||||||
|
sendInput.mockRejectedValueOnce(new Error("pty gone"));
|
||||||
|
render(<SendToAgentButton projectId="p1" body="hello" />);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /send to agent/i }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(pushToast).toHaveBeenCalled());
|
||||||
|
expect(requestTerminalFocus).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("focuses the session picked from the menu, not the first one", async () => {
|
||||||
|
sessions = [session(), session({ id: "s2", sessionName: "review" })];
|
||||||
|
render(<SendToAgentButton projectId="p1" body="hello" />);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /send to agent/i }));
|
||||||
|
fireEvent.click(await screen.findByRole("menuitem", { name: "review" }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(requestTerminalFocus).toHaveBeenCalledWith("s2"));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -36,10 +36,12 @@ export default function SendToAgentButton({
|
|||||||
fullWidth = false,
|
fullWidth = false,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const { sessions, sendInput } = useTerminal();
|
const { sessions, sendInput } = useTerminal();
|
||||||
const { projects, setActiveTabKey, pushToast } = useAppState(
|
const { projects, setActiveTabKey, requestTerminalFocus, pushToast } =
|
||||||
|
useAppState(
|
||||||
useShallow((s) => ({
|
useShallow((s) => ({
|
||||||
projects: s.projects,
|
projects: s.projects,
|
||||||
setActiveTabKey: s.setActiveTabKey,
|
setActiveTabKey: s.setActiveTabKey,
|
||||||
|
requestTerminalFocus: s.requestTerminalFocus,
|
||||||
pushToast: s.pushToast,
|
pushToast: s.pushToast,
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
@@ -86,6 +88,11 @@ export default function SendToAgentButton({
|
|||||||
// A courtesy, not part of the send: if the tab cannot be focused the
|
// A courtesy, not part of the send: if the tab cannot be focused the
|
||||||
// text still went.
|
// text still went.
|
||||||
setActiveTabKey(terminalTabKey(sessionId));
|
setActiveTabKey(terminalTabKey(sessionId));
|
||||||
|
// Switching tabs is not the same as taking focus, and when the dock is
|
||||||
|
// open beside the terminal it just sent to, that tab is already the
|
||||||
|
// active one — so nothing above moves the caret off this button. The
|
||||||
|
// note is sitting in the prompt waiting for Enter; put the user there.
|
||||||
|
requestTerminalFocus(sessionId);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
pushToast({
|
pushToast({
|
||||||
kind: "error",
|
kind: "error",
|
||||||
@@ -94,7 +101,7 @@ export default function SendToAgentButton({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[body, sendInput, setActiveTabKey, pushToast],
|
[body, sendInput, setActiveTabKey, requestTerminalFocus, pushToast],
|
||||||
);
|
);
|
||||||
|
|
||||||
const onClick = useCallback(() => {
|
const onClick = useCallback(() => {
|
||||||
|
|||||||
@@ -538,3 +538,59 @@ describe("TerminalView — reaching the URL prompt without a mouse", () => {
|
|||||||
expect(document.activeElement).toBe(before);
|
expect(document.activeElement).toBe(before);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("TerminalView — focus on request", () => {
|
||||||
|
/** Mount, then deliberately give focus away, so what the assertions below
|
||||||
|
* observe is the *request* taking effect and never the focus `active`
|
||||||
|
* already grants on mount. That distinction is the whole point: the notes
|
||||||
|
* dock sends to a terminal whose tab is already active, where nothing
|
||||||
|
* changes and no `active` effect re-runs. */
|
||||||
|
async function mountAndBlur() {
|
||||||
|
const view = mountSession("claude");
|
||||||
|
await act(async () => {});
|
||||||
|
const elsewhere = document.createElement("button");
|
||||||
|
document.body.appendChild(elsewhere);
|
||||||
|
elsewhere.focus();
|
||||||
|
expect(document.activeElement).toBe(elsewhere);
|
||||||
|
return view;
|
||||||
|
}
|
||||||
|
|
||||||
|
it("focuses the terminal named by the request", async () => {
|
||||||
|
const view = await mountAndBlur();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
useAppState.getState().requestTerminalFocus("s1");
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(document.activeElement).toBe(helperTextarea(view.container));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores a request meant for another session", async () => {
|
||||||
|
const view = await mountAndBlur();
|
||||||
|
const before = document.activeElement;
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
useAppState.getState().requestTerminalFocus("s2");
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(document.activeElement).toBe(before);
|
||||||
|
expect(document.activeElement).not.toBe(helperTextarea(view.container));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears the request, so a second send focuses again", async () => {
|
||||||
|
const view = await mountAndBlur();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
useAppState.getState().requestTerminalFocus("s1");
|
||||||
|
});
|
||||||
|
expect(useAppState.getState().pendingTerminalFocus).toBeNull();
|
||||||
|
|
||||||
|
const elsewhere = document.querySelector("button");
|
||||||
|
(elsewhere as HTMLButtonElement).focus();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
useAppState.getState().requestTerminalFocus("s1");
|
||||||
|
});
|
||||||
|
expect(document.activeElement).toBe(helperTextarea(view.container));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -731,6 +731,21 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
}
|
}
|
||||||
}, [active, gpuRenderingSetting]);
|
}, [active, gpuRenderingSetting]);
|
||||||
|
|
||||||
|
// Focus on demand, for the caller that cannot rely on the effect above.
|
||||||
|
// That one keys off `active`, so it covers switching *to* a terminal and
|
||||||
|
// nothing else — and the notes dock sends to the terminal already on screen,
|
||||||
|
// where `active` never changes. Consumed once and cleared, so asking twice
|
||||||
|
// for the same terminal works.
|
||||||
|
const pendingTerminalFocus = useAppState((s) => s.pendingTerminalFocus);
|
||||||
|
const clearPendingTerminalFocus = useAppState(
|
||||||
|
(s) => s.clearPendingTerminalFocus,
|
||||||
|
);
|
||||||
|
useEffect(() => {
|
||||||
|
if (pendingTerminalFocus !== sessionId) return;
|
||||||
|
termRef.current?.focus();
|
||||||
|
clearPendingTerminalFocus();
|
||||||
|
}, [pendingTerminalFocus, sessionId, clearPendingTerminalFocus]);
|
||||||
|
|
||||||
// Auto-dismiss toast after 30 seconds — unless the user is standing in it.
|
// Auto-dismiss toast after 30 seconds — unless the user is standing in it.
|
||||||
// A keyboard user who has just jumped into the toast is mid-decision, and
|
// A keyboard user who has just jumped into the toast is mid-decision, and
|
||||||
// pulling it out from under them costs them the only route to finishing a
|
// pulling it out from under them costs them the only route to finishing a
|
||||||
|
|||||||
@@ -112,3 +112,27 @@ describe("toasts", () => {
|
|||||||
expect(toasts()).toHaveLength(2);
|
expect(toasts()).toHaveLength(2);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("terminal focus requests", () => {
|
||||||
|
beforeEach(() => useAppState.setState({ pendingTerminalFocus: null }));
|
||||||
|
|
||||||
|
const pending = () => useAppState.getState().pendingTerminalFocus;
|
||||||
|
|
||||||
|
it("names the session that should take focus", () => {
|
||||||
|
useAppState.getState().requestTerminalFocus("s1");
|
||||||
|
expect(pending()).toBe("s1");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Consumed once, exactly like `pendingHomeTab`. Without the clear, the
|
||||||
|
// second send to a terminal already holding the request would set the same
|
||||||
|
// value, no state would change, and no effect would re-run — which is the
|
||||||
|
// failure this whole mechanism exists to fix.
|
||||||
|
it("is cleared once consumed, so the same terminal can be asked again", () => {
|
||||||
|
useAppState.getState().requestTerminalFocus("s1");
|
||||||
|
useAppState.getState().clearPendingTerminalFocus();
|
||||||
|
expect(pending()).toBeNull();
|
||||||
|
|
||||||
|
useAppState.getState().requestTerminalFocus("s1");
|
||||||
|
expect(pending()).toBe("s1");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -144,6 +144,21 @@ interface AppState {
|
|||||||
/** Consumed once by `ProjectHome`, then cleared. */
|
/** Consumed once by `ProjectHome`, then cleared. */
|
||||||
pendingHomeTab: { projectId: string; tab: string } | null;
|
pendingHomeTab: { projectId: string; tab: string } | null;
|
||||||
clearPendingHomeTab: () => void;
|
clearPendingHomeTab: () => void;
|
||||||
|
/**
|
||||||
|
* Ask a terminal to take keyboard focus.
|
||||||
|
*
|
||||||
|
* `TerminalView` already focuses when its tab *becomes* active, which covers
|
||||||
|
* switching to a terminal. It cannot cover being asked to focus the terminal
|
||||||
|
* that is already on screen — nothing changes, so no effect re-runs — and
|
||||||
|
* that is the ordinary case for the notes dock, which sits beside the
|
||||||
|
* terminal it sends to.
|
||||||
|
*
|
||||||
|
* Consumed once and cleared, like `pendingHomeTab`: holding the id would
|
||||||
|
* make a second request for the same terminal a no-op state write.
|
||||||
|
*/
|
||||||
|
pendingTerminalFocus: string | null;
|
||||||
|
requestTerminalFocus: (sessionId: string) => void;
|
||||||
|
clearPendingTerminalFocus: () => void;
|
||||||
closeHomeTab: (projectId: string) => void;
|
closeHomeTab: (projectId: string) => void;
|
||||||
setActiveTabKey: (key: string) => void;
|
setActiveTabKey: (key: string) => void;
|
||||||
cycleTab: (delta: number) => void;
|
cycleTab: (delta: number) => void;
|
||||||
@@ -352,6 +367,9 @@ export const useAppState = create<AppState>((set) => ({
|
|||||||
}),
|
}),
|
||||||
pendingHomeTab: null,
|
pendingHomeTab: null,
|
||||||
clearPendingHomeTab: () => set({ pendingHomeTab: null }),
|
clearPendingHomeTab: () => set({ pendingHomeTab: null }),
|
||||||
|
pendingTerminalFocus: null,
|
||||||
|
requestTerminalFocus: (sessionId) => set({ pendingTerminalFocus: sessionId }),
|
||||||
|
clearPendingTerminalFocus: () => set({ pendingTerminalFocus: null }),
|
||||||
closeHomeTab: (projectId) =>
|
closeHomeTab: (projectId) =>
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const key = homeTabKey(projectId);
|
const key = homeTabKey(projectId);
|
||||||
|
|||||||
Executable
+159
@@ -0,0 +1,159 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# Drop the bundled libwayland-client.so.0 out of a built AppImage.
|
||||||
|
#
|
||||||
|
# linuxdeploy-plugin-gtk bundles libwayland-client.so.0 as a dependency of
|
||||||
|
# GTK, and `AppRun.wrapped` puts the bundled lib directory ahead of the host's
|
||||||
|
# on the loader path. The host's Mesa then resolves its Wayland EGL platform
|
||||||
|
# against *our* copy instead of the system one it was built against, and when
|
||||||
|
# ours is older than Mesa needs, EGL initialisation fails outright:
|
||||||
|
#
|
||||||
|
# Could not create default EGL display: EGL_BAD_PARAMETER. Aborting...
|
||||||
|
#
|
||||||
|
# WebKitGTK prints that from its own C code and kills the webview, so the
|
||||||
|
# window comes up blank. Measured on CachyOS with wayland 1.26 / Mesa 26.2.1
|
||||||
|
# against an AppImage built on Ubuntu 22.04 (wayland 1.20): eleven symbols
|
||||||
|
# Mesa can ask for are missing from the bundled copy, `wl_proxy_get_display`,
|
||||||
|
# `wl_proxy_get_queue`, `wl_display_create_queue_with_name` and
|
||||||
|
# `wl_fixes_interface` among them. Removing this one file from the AppDir
|
||||||
|
# fixes it; removing libwayland-egl or libepoxy does not.
|
||||||
|
#
|
||||||
|
# **Building on a newer runner would not fix this.** libwayland-client is a
|
||||||
|
# host-coupled library in the same way libGL, libEGL and libdrm are: it has to
|
||||||
|
# match the compositor and Mesa actually running, not the ones the build
|
||||||
|
# machine had. Any pinned version is wrong on a system newer than the builder,
|
||||||
|
# so the only correct version is the host's. That is what AppImage excludelists
|
||||||
|
# are for; this library simply is not on linuxdeploy's.
|
||||||
|
#
|
||||||
|
# Bundling a *newer* wayland instead would not fix this either, only defer it.
|
||||||
|
# The version floor is set by the host's Mesa: `libEGL_mesa.so.0` — the driver
|
||||||
|
# libglvnd's `libEGL.so.1` dlopens — carries a hard DT_NEEDED on
|
||||||
|
# libwayland-client.so.0. If those symbols will not resolve, the driver never
|
||||||
|
# loads, glvnd is left with none, and `eglGetDisplay` reports no display. That
|
||||||
|
# is why forcing GDK_BACKEND=x11 does not dodge it, and why the symptom is a
|
||||||
|
# bad-parameter error rather than a link failure. Their Mesa updates independently of our releases, so any version
|
||||||
|
# we pick is one wayland release away from being too old again.
|
||||||
|
#
|
||||||
|
# So the copy is not deleted, it is demoted. It moves to a directory that is
|
||||||
|
# not on the loader path, and a hook puts that directory on the path only when
|
||||||
|
# the host has no libwayland-client of its own. Hosts with one — which is
|
||||||
|
# every host with a graphical desktop, since Mesa itself depends on it — get
|
||||||
|
# theirs, matching their Mesa. A host without one still gets a working app.
|
||||||
|
#
|
||||||
|
# The ordering works because `AppRun.wrapped` appends the inherited
|
||||||
|
# LD_LIBRARY_PATH after its own AppDir entries, so anything the hook exports
|
||||||
|
# lands last: a fallback, never an override.
|
||||||
|
#
|
||||||
|
# Usage: unbundle-wayland-client.sh <directory holding the .AppImage>
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
LIB="libwayland-client.so.0"
|
||||||
|
FALLBACK_DIR="usr/lib/wayland-fallback"
|
||||||
|
HOOK="apprun-hooks/triple-c-wayland-fallback.sh"
|
||||||
|
APPIMAGE_TOOL_URL="https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage"
|
||||||
|
|
||||||
|
dir="${1:?usage: unbundle-wayland-client.sh <bundle/appimage directory>}"
|
||||||
|
cd "$dir"
|
||||||
|
|
||||||
|
shopt -s nullglob
|
||||||
|
images=(*.AppImage)
|
||||||
|
shopt -u nullglob
|
||||||
|
if [ ${#images[@]} -eq 0 ]; then
|
||||||
|
echo "No .AppImage in $dir — nothing to do." >&2
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
appimage="${images[0]}"
|
||||||
|
here="$PWD"
|
||||||
|
|
||||||
|
work="$(mktemp -d)"
|
||||||
|
check="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "$work" "$check"' EXIT
|
||||||
|
|
||||||
|
echo "Inspecting $appimage"
|
||||||
|
( cd "$work" && "$here/$appimage" --appimage-extract >/dev/null )
|
||||||
|
root="$work/squashfs-root"
|
||||||
|
|
||||||
|
if [ ! -e "$root/usr/lib/$LIB" ]; then
|
||||||
|
# Not a failure: linuxdeploy may have stopped bundling it, which is the
|
||||||
|
# outcome this script exists to produce.
|
||||||
|
echo "$LIB is not bundled — leaving $appimage alone."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$root/$FALLBACK_DIR"
|
||||||
|
mv "$root/usr/lib/$LIB" "$root/$FALLBACK_DIR/$LIB"
|
||||||
|
|
||||||
|
cat > "$root/$HOOK" <<'HOOK_EOF'
|
||||||
|
#! /usr/bin/env bash
|
||||||
|
# Fall back to the bundled libwayland-client only when the host has none.
|
||||||
|
#
|
||||||
|
# The host's copy is the correct one whenever it exists: its Mesa was built
|
||||||
|
# against it, and `libEGL.so.1` needs symbols from it before it will load.
|
||||||
|
# Ours is here so a host without any libwayland-client still starts.
|
||||||
|
#
|
||||||
|
# This runs before AppRun.wrapped, which appends the inherited
|
||||||
|
# LD_LIBRARY_PATH after its own entries — so this is always a fallback.
|
||||||
|
_tc_host_has_wayland_client() {
|
||||||
|
if command -v ldconfig >/dev/null 2>&1 &&
|
||||||
|
ldconfig -p 2>/dev/null | grep -q "libwayland-client\.so\.0"; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
local d
|
||||||
|
for d in /usr/lib /usr/lib64 /usr/lib/x86_64-linux-gnu \
|
||||||
|
/lib /lib64 /lib/x86_64-linux-gnu; do
|
||||||
|
[ -e "$d/libwayland-client.so.0" ] && return 0
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if ! _tc_host_has_wayland_client; then
|
||||||
|
_TC_APPDIR="${APPDIR:-"$(dirname "$(readlink -f "$0")")/.."}"
|
||||||
|
export LD_LIBRARY_PATH="${_TC_APPDIR}/usr/lib/wayland-fallback${LD_LIBRARY_PATH:+:${LD_LIBRARY_PATH}}"
|
||||||
|
fi
|
||||||
|
unset -f _tc_host_has_wayland_client
|
||||||
|
HOOK_EOF
|
||||||
|
chmod +x "$root/$HOOK"
|
||||||
|
|
||||||
|
# AppRun sources each hook by name rather than globbing the directory, so a
|
||||||
|
# new hook file is inert until AppRun is told about it.
|
||||||
|
if ! grep -q "triple-c-wayland-fallback" "$root/AppRun"; then
|
||||||
|
python3 - "$root/AppRun" <<'PATCH_EOF'
|
||||||
|
import sys
|
||||||
|
path = sys.argv[1]
|
||||||
|
src = open(path).read()
|
||||||
|
exec_line = 'exec "$this_dir"/AppRun.wrapped "$@"'
|
||||||
|
if exec_line not in src:
|
||||||
|
raise SystemExit("AppRun does not have the exec line this patch expects")
|
||||||
|
src = src.replace(
|
||||||
|
exec_line,
|
||||||
|
'source "$this_dir"/apprun-hooks/"triple-c-wayland-fallback.sh"\n' + exec_line,
|
||||||
|
)
|
||||||
|
open(path, "w").write(src)
|
||||||
|
PATCH_EOF
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Demoted $LIB to $FALLBACK_DIR; repacking."
|
||||||
|
|
||||||
|
tool="$work/appimagetool"
|
||||||
|
curl -fsSL -o "$tool" "$APPIMAGE_TOOL_URL"
|
||||||
|
chmod +x "$tool"
|
||||||
|
|
||||||
|
# --appimage-extract-and-run: CI runners generally have no FUSE.
|
||||||
|
ARCH=x86_64 "$tool" --appimage-extract-and-run "$root" "$appimage" >/dev/null
|
||||||
|
chmod +x "$appimage"
|
||||||
|
|
||||||
|
# The guards are the test. Each one is a way the repack could look like it
|
||||||
|
# worked while shipping the original bug.
|
||||||
|
( cd "$check" && "$here/$appimage" --appimage-extract >/dev/null )
|
||||||
|
out="$check/squashfs-root"
|
||||||
|
|
||||||
|
fail() { echo "FAILED: $1" >&2; exit 1; }
|
||||||
|
|
||||||
|
[ -e "$out/usr/lib/$LIB" ] && fail "$LIB is still on the loader path."
|
||||||
|
[ -e "$out/$FALLBACK_DIR/$LIB" ] || fail "the fallback copy of $LIB is missing."
|
||||||
|
[ -e "$out/$HOOK" ] || fail "the fallback hook is missing."
|
||||||
|
grep -q "triple-c-wayland-fallback" "$out/AppRun" || fail "AppRun does not source the hook."
|
||||||
|
[ -x "$out/usr/bin/triple-c" ] || fail "no executable usr/bin/triple-c."
|
||||||
|
|
||||||
|
echo "OK: $appimage now prefers the host $LIB, with a bundled fallback."
|
||||||
Reference in New Issue
Block a user