Fix linux issues (#375)

* Support scroll on linux

* Close tab on middle-click of the X button too

The X icon only had onClick, which browsers never fire for the middle
button. Wire it to the same middle-click handler already used on the
tab body so middle-clicking the X closes the tab like clicking
anywhere else on it does.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* Spawn shell via flatpak-spawn --host on Flatpak builds

The Flatpak build sandboxes the shell inside the app's mount namespace,
so PATH ends up as whatever the runtime provides (/app/bin:/usr/bin)
with no access to /usr/local or /home/linuxbrew, unlike an unsandboxed
terminal like Konsole. Route the pty through flatpak-spawn --host so
the shell actually runs on the host and its --login profile computes a
normal host PATH, and grant the org.freedesktop.Flatpak talk-name the
portal needs for this.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* Document Flatpak shell-spawning fix and bare-shell-name limitation

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* Fix prettier lint in session.ts and tabs.tsx

Both fit within the 120-column print width, so prettier wants them on
one line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Philip Peterson 2026-10-11 08:00:02 -08:00 committed by GitHub
parent c066cdfabc
commit e2de7e18d1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 96 additions and 5 deletions

View file

@ -10,6 +10,7 @@ import * as config from './config';
import {cliScriptPath} from './config/paths';
import {productName, version} from './package.json';
import {getDecoratedEnv} from './plugins';
import {isFlatpak} from './utils/flatpak';
import {getFallBackShellConfig} from './utils/shell-fallback';
const createNodePtyError = () =>
@ -154,11 +155,28 @@ export default class Session extends EventEmitter {
delete baseEnv.GOOGLE_API_KEY;
}
const ptyEnv = getDecoratedEnv(baseEnv);
// When running as a Flatpak, this process (and anything node-pty spawns directly)
// is confined to the sandbox's mount namespace, where PATH is whatever the runtime
// sets (e.g. /app/bin:/usr/bin) and host-only locations like /usr/local or
// /home/linuxbrew aren't even mounted. Route the shell through `flatpak-spawn --host`
// so it actually runs on the host, like a normal terminal, and drop our sandboxed
// PATH so the host login shell computes its own from the host's profile scripts
// instead of inheriting the sandbox's.
let spawnCommand = shell;
let spawnArgs = shellArgs;
if (isFlatpak()) {
delete ptyEnv['PATH'];
spawnCommand = 'flatpak-spawn';
spawnArgs = ['--host', '--watch-bus', ...(cwd ? [`--directory=${cwd}`] : []), shell, ...shellArgs];
}
const options: IWindowsPtyForkOptions = {
cols,
rows,
cwd,
env: getDecoratedEnv(baseEnv)
env: ptyEnv
};
// if config do not set the useConpty, it will be judged by the node-pty
@ -166,9 +184,9 @@ export default class Session extends EventEmitter {
options.useConpty = useConpty;
}
console.log('[session] spawning pty, shell:', shell, 'args:', shellArgs, 'useConpty:', options.useConpty);
console.log('[session] spawning pty, command:', spawnCommand, 'args:', spawnArgs, 'useConpty:', options.useConpty);
try {
this.pty = spawn(shell, shellArgs, options);
this.pty = spawn(spawnCommand, spawnArgs, options);
console.log('[session] pty spawned, pid:', this.pty.pid);
} catch (_err) {
const err = _err as {message: string; stack?: string};

5
app/utils/flatpak.ts Normal file
View file

@ -0,0 +1,5 @@
import {existsSync} from 'fs';
// Every process running inside a Flatpak sandbox has FLATPAK_ID set and
// a /.flatpak-info file bind-mounted in, regardless of runtime or app.
export const isFlatpak = (): boolean => Boolean(process.env.FLATPAK_ID) || existsSync('/.flatpak-info');

58
docs/flatpak-shell.md Normal file
View file

@ -0,0 +1,58 @@
# Shell spawning under Flatpak
## Background
Hyper's Flatpak build (`electron-builder.json` → `flatpak`) sandboxes the app.
Historically this meant the pty shell was spawned *inside* the sandbox's mount
namespace, where `PATH` is whatever the runtime provides
(`/app/bin:/usr/bin`) and host-only locations like `/usr/local` or
`/home/linuxbrew` aren't mounted at all — unlike an unsandboxed terminal such
as Konsole, which inherits the full host `PATH` from the user's shell
profile.
## Fix
`app/session.ts` now detects the sandbox via `app/utils/flatpak.ts`
(`isFlatpak()`, checking `FLATPAK_ID` / `/.flatpak-info`) and, when sandboxed,
spawns the shell through `flatpak-spawn --host` instead of directly:
```
flatpak-spawn --host --watch-bus --directory=<cwd> <shell> <shellArgs>
```
`--host` runs `<shell>` on the host via the `org.freedesktop.Flatpak` portal,
so it's the real host binary (e.g. the host's `/bin/bash`), not anything
from `/app` or the runtime's `/usr`. The sandboxed `PATH` is deleted from the
env passed to `flatpak-spawn` so the host's own `--login` shell profile
computes `PATH` from scratch, matching what Konsole would produce. All other
env vars (`DISPLAY`, `DBUS_SESSION_BUS_ADDRESS`, `XDG_RUNTIME_DIR`,
`SSH_AUTH_SOCK`, `TERM`, etc.) are still forwarded as-is.
`electron-builder.json`'s Flatpak `finishArgs` gained
`--talk-name=org.freedesktop.Flatpak`, which `flatpak-spawn --host` requires.
Non-Flatpak builds (deb/rpm/snap/pacman/AppImage/Win/Mac) are unaffected —
they still spawn the shell directly.
## Known limitation: bare shell names
`<shell>` is passed to `flatpak-spawn --host` as a literal argument and is
**not** resolved against any `PATH` — it only works because the shell value
is normally an absolute path (`default-shell` resolves `process.env.SHELL`,
falling back to `/bin/bash`).
If a user's Hyper profile config sets a custom `shell` as a **bare command
name** instead of an absolute path (e.g. `"shell": "fish"` rather than
`"shell": "/usr/bin/fish"`), spawning will fail under the Flatpak build:
there's no `PATH` on the host-exec side to search, since `PATH` is
intentionally stripped before the sandboxed `PATH` value can leak to the
host's `--login` shell (see "Fix" above).
This is a pre-existing footgun made slightly worse by this change — not
currently handled specially. If it needs fixing, the options are:
- Resolve bare shell names to absolute paths before constructing
`spawnArgs` in `app/session.ts` (e.g. via a `flatpak-spawn --host which
<shell>` lookup, or requiring absolute paths in profile config).
- Document the requirement in `README.md`/config docs so profile authors
know to use absolute paths for `shell` on Linux.

View file

@ -129,6 +129,7 @@
"--filesystem=home",
"--share=network",
"--talk-name=org.freedesktop.Notifications",
"--talk-name=org.freedesktop.Flatpak",
"--socket=ssh-auth"
]
},

View file

@ -51,7 +51,7 @@ function Tab(props: TabProps) {
{props.text}
</span>
</span>
<i className="tab_icon" onClick={props.onClose}>
<i className="tab_icon" onClick={props.onClose} onMouseUp={handleMouseUp}>
<svg className="tab_shape">
<use xlinkHref="./renderer/assets/icons.svg#close-tab" />
</svg>

View file

@ -33,13 +33,22 @@ const Tabs = forwardRef<HTMLElement, TabsProps>((props, ref) => {
const hide = !isMac && tabs.length === 1;
const handleWheel = (event: React.WheelEvent<HTMLUListElement>) => {
// A plain vertical mouse wheel only ever reports deltaY. macOS trackpads
// generate real deltaX for horizontal swipes, but a wheel mouse (common
// on Linux/Windows) doesn't, so without this the tab list never scrolls.
if (event.deltaX === 0 && event.deltaY !== 0) {
event.currentTarget.scrollLeft += event.deltaY;
}
};
return (
<nav className={`tabs_nav ${hide ? 'tabs_hiddenNav' : ''}`} ref={ref}>
{props.customChildrenBefore}
{tabs.length === 1 && isMac ? <div className="tabs_title">{tabs[0].title}</div> : null}
{tabs.length > 1 ? (
<>
<ul key="list" className={`tabs_list ${fullScreen && isMac ? 'tabs_fullScreen' : ''}`}>
<ul key="list" className={`tabs_list ${fullScreen && isMac ? 'tabs_fullScreen' : ''}`} onWheel={handleWheel}>
{tabs.map((tab, i) => {
const {uid, title, isActive, hasActivity} = tab;
const tabProps = getTabProps(tab, props, {