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
64 changes: 44 additions & 20 deletions apps/desktop/launcher/tron-pwa
Original file line number Diff line number Diff line change
Expand Up @@ -77,40 +77,64 @@ KEEP_SWITCHES = (


def unescape_exec(value: str) -> list[str]:
"""Split a desktop-entry Exec value into argv.
"""Split a desktop-entry Exec value into argv, the way the desktop does.

Quoting is the desktop spec's own: double quotes group, and inside them a
backslash escapes `"`, `` ` ``, `$` and `\\`. Splitting on whitespace alone
would break every profile path that has a space in it.
Follow GLib's g_shell_parse_argv rather than the desktop-entry spec. The
spec defines only double quotes, but GLib is what actually launches these
entries, and it honours SINGLE quotes too -- so writers use them. Flatpak's
flextop exports every argument single-quoted:

Exec=flatpak 'run' '--command=/app/bin/chromium' '--app-id=abc…'

Parsed to the spec, `'--app-id=abc…'` is one literal token with an
apostrophe on the front, so every test for a switch fails and the whole file
reads as "not a web app". Being stricter than the launcher here means not
seeing files that work perfectly well for everyone else.

Rules: outside quotes a backslash escapes the next character; single quotes
are literal to the next single quote with no escapes inside; double quotes
are literal except that a backslash escapes `"`, `` ` ``, `$` and `\\`.
"""
args: list[str] = []
cur = ""
in_quotes = False
started = False
i = 0
while i < len(value):
c = value[i]
if in_quotes:
if c == "\\" and i + 1 < len(value) and value[i + 1] in '"`$\\':
cur += value[i + 1]
i += 2
continue
if c == '"':
in_quotes = False
if c == "'":
started = True
i += 1
while i < len(value) and value[i] != "'":
cur += value[i]
i += 1
continue
cur += c
elif c == '"':
in_quotes = True
i += 1 # closing quote (or end of string, if unbalanced)
continue
if c == '"':
started = True
elif c.isspace():
i += 1
while i < len(value) and value[i] != '"':
if value[i] == "\\" and i + 1 < len(value) and value[i + 1] in '"`$\\':
cur += value[i + 1]
i += 2
continue
cur += value[i]
i += 1
i += 1
continue
if c == "\\" and i + 1 < len(value):
cur += value[i + 1]
started = True
i += 2
continue
if c.isspace():
if started:
args.append(cur)
cur = ""
started = False
else:
cur += c
started = True
i += 1
continue
cur += c
started = True
i += 1
if started:
args.append(cur)
Expand Down
100 changes: 100 additions & 0 deletions apps/desktop/test/pwa.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -397,6 +397,106 @@ describe('Flatpak flextop exports', () => {
});
});

describe('single-quoted Exec values', () => {
// Verbatim from a real Flathub install. The desktop-entry spec defines only
// double quotes, but GLib -- which is what actually launches these -- honours
// single quotes, so writers use them. Parsed to the spec, every argument here
// is a literal token with an apostrophe on the front, so every switch test
// fails and the file reads as "not a web app". That is how eleven working
// shortcuts stayed invisible to two releases of this helper.
const REAL_EXEC =
"flatpak 'run' '--command=/app/bin/chromium' " +
"'io.github.ungoogled_software.ungoogled_chromium' " +
`'--user-data-dir=PROFILE' '--profile-directory=Default' '--app-id=${APP_ID}'`;

function writeReal(env: Env): string {
const file = `io.github.ungoogled_software.ungoogled_chromium.flextop.chrome-${APP_ID}-Default.desktop`;
writeFileSync(
join(env.apps, file),
[
'[Desktop Entry]',
'Version=1.0',
'Terminal=false',
'Type=Application',
'Name=Sulata Note',
`Exec=${REAL_EXEC.replace('PROFILE', env.profile)}`,
`Icon=chrome-${APP_ID}-Default`,
`StartupWMClass=crx_${APP_ID}`,
'X-Flatpak-Part-Of=io.github.ungoogled_software.ungoogled_chromium',
'TryExec=/var/lib/flatpak/exports/bin/io.github.ungoogled_software.ungoogled_chromium',
'',
].join('\n'),
);
return file;
}

it('recognises a single-quoted --app-id as a web app at all', () => {
const env = setup();
writeReal(env);

expect(run(env, ['list']).stdout).toContain('Sulata Note');
});

it('rewrites it, keeping the app and profile and dropping the flatpak wrapper', () => {
const env = setup();
const file = writeReal(env);

run(env, ['sync']);

const [exec] = execLines(shortcut(file)(env));
expect(exec.split(' ')[0]).toBe(CLI);
expect(exec).toContain(`--app-id=${APP_ID}`);
expect(exec).toContain(`--user-data-dir=${env.profile}`);
expect(exec).toContain('--profile-directory=Default');
expect(exec).not.toContain('--command=');
expect(exec).not.toContain("'");
});

it('reverts byte-for-byte, single quotes and all', () => {
const env = setup();
const file = writeReal(env);
const before = shortcut(file)(env);

run(env, ['sync']);
run(env, ['revert']);

expect(shortcut(file)(env)).toBe(before);
});

it('still parses double quotes and backslash escapes', () => {
const env = setup();
const profile = join(env.home, 'a b');
mkdirSync(profile, { recursive: true });
const file = `chrome-${APP_ID}-Default.desktop`;
writeFileSync(
join(env.apps, file),
[
'[Desktop Entry]',
'Type=Application',
'Name=Mixed',
`Exec=/app/chromium/chrome "--user-data-dir=${profile}" '--app-id=${APP_ID}'`,
'',
].join('\n'),
);

const result = spawnSync('python3', [TRON_PWA, 'sync'], {
encoding: 'utf8',
env: {
PATH: process.env.PATH ?? '/usr/bin:/bin',
HOME: env.home,
XDG_DATA_HOME: join(env.home, '.local', 'share'),
TRONBROWSER_DATA: profile,
TRONBROWSER_CLI: CLI,
},
});
expect(result.status).toBe(0);

const [exec] = execLines(shortcut(file)(env));
expect(exec).toContain(`"--user-data-dir=${profile}"`);
expect(exec).toContain(`--app-id=${APP_ID}`);
});
});

describe('the launcher runs the sync itself', () => {
// A repair nobody invokes is not a fix. The engine rewrites these shortcuts
// behind us, so the browser has to re-run this on every start — which means
Expand Down
Loading