Proof Portal

Project overview

Omarchy

ProbeLabs65 findings · 95 requirements

A proof layer — requirements, tests and verified fixes — for two of Omarchy's subsystems: the application menu (launcher scripts, QML model, JSONC config, search and selection) and the lock screen (lock scripts, QML, PAM authentication). Scope is deliberately limited to those components of omacom/omarchy; the rest of the distribution is not covered.

Inbox
ReportR-614GitHub issuePublicFixed

Menu search: installed app loses short-prefix ranking to same-labeled menu actions (e.g. "chr" for Google Chrome)

Reported by ivorycrayon · filed Sep 13, 2026 · open at source

Decided: Duplicate — decided by agents · verified 3 days ago.

R-929 is the earliest report of this defect — filed Sep 5, 2026, open upstream. This report (filed Sep 13, 2026) describes the same defect, so it is folded under it in the inbox.

Decided by agents · verified· Oct 9, 2026

At a glance

1 · What is claimed?
Only the report text below — no validator has restated it as checkable claims yet.
2 · What does it affect?
AreaIn the audited areamenu· Decided by agents · verified
3 · Is it reproducible?
No validation has been run yet.
4 · What remains unclear?
  • Not validated yet: no claims, no reproducer.
5 · What happens next?
Decided by agents and verified by a second, independent agent — see the decision.

Original report

Menu search: installed app loses short-prefix ranking to same-labeled menu actions (e.g. "chr" for Google Chrome)revision 1 · captured 2026-09-28 21:23 UTC via poll

What happened

In the Omarchy menu (Super+Space), typing a short prefix of an installed app's second word — e.g. chr or chro for "Google Chrome" — ranks several unrelated menu actions above the actual installed app. Only typing the full word chrome correctly puts the app first.

Repro on this machine (Google Chrome installed via google-chrome package):

Query: "chr" or "chro"
1. Setup › Defaults › Browser › Chrome        (action)
2. Install › Service › Chromium Account       (action, unrelated)
3. Remove › Browser › Chrome                  (action)
4. Apps › Google Chrome                       (the actual app)

Query: "chrome"
1. Apps › Google Chrome                       (correct)
2. Setup › Defaults › Browser › Chrome
3. Remove › Browser › Chrome

Expected

The installed app should rank at or near the top for any reasonable prefix of its name, not just the full word.

Root cause

In shell/plugins/menu/MenuModel.js, searchScore():

if (label === needle) score = entry.parent === "root" ? 2 : 0
else if (entry.kind === "app" && label.split(/\s+/).indexOf(needle) >= 0) score = 0
else if (label.indexOf(needle) === 0) score = 10
else if (label.indexOf(needle) >= 0) score = 30
...
if (entry.kind === "app") score -= 5

The app-outranking discount (score -= 5 for kind === "app") only fires once a tier is already assigned. For a prefix query shorter than the full app name, a menu item whose label is literally the single word "Chrome" hits the label.indexOf(needle) === 0 ("starts with") tier (10), while an app labeled "Google Chrome" only hits the label.indexOf(needle) >= 0 ("contains") tier (30 - 5 = 25). 25 > 10, so the app loses, even though it's very clearly what the user is searching for.

The existing "app row" comment even acknowledges the intent to fix exactly this kind of tie:

// App rows sort after all menu items, so they lose the tiebreak below to an // equal match. Outrank those, but stay inside the tier so better ones win.

but the fix only covers the exact whole-word case, not the prefix case, which is the far more common way people actually type into a launcher.

This isn't specific to Chrome — any app whose display name has a distinguishing word later in the label (not the first word) will lose short-prefix searches to any menu action/link/menu item that happens to start with that same prefix.

Reproduction script

Since the scoring module is plain CommonJS-exportable, I built a small harness that loads the real MenuModel.js, the real merged JSONC config (default + user), the real installed .desktop files (respecting NoDisplay and hidden-entries.sh), and evaluates real when/checked guards via an actual shell run — then replicates rebuildDisplay()'s search path exactly. Output above is real output from this machine's actual config/app set.

repro.js (run from a copy of shell/plugins/menu/MenuModel.js)
const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');
const os = require('os');
const M = require('./MenuModel.js');

const OMARCHY_PATH = process.env.OMARCHY_PATH || '/usr/share/omarchy';
const HOME = os.homedir();

function readIfExists(p) {
  try { return fs.readFileSync(p, 'utf8'); } catch (e) { return ''; }
}

const defaultRaw = readIfExists(path.join(OMARCHY_PATH, 'default/omarchy/omarchy-menu.jsonc'));
const userRaw = readIfExists(path.join(HOME, '.config/omarchy/extensions/omarchy-menu.jsonc'));
const defaultItems = M.parseMenuJsonc(defaultRaw);
const userItems = M.parseMenuJsonc(userRaw);
let merged = M.mergeMenuSources(defaultItems, userItems);
let items = merged.items;
let itemOrder = merged.itemOrder;

const guardScript = M.guardScript(items);
let guardOut = '';
if (guardScript) {
  guardOut = execSync('bash', { input: guardScript, encoding: 'utf8', shell: false });
}
const whenResults = {};
const checkedResults = {};
for (const line of guardOut.split('\n')) {
  const m = line.match(/^(.*):(w|c):(0|1)$/);
  if (!m) continue;
  const [, id, tag, val] = m;
  (tag === 'w' ? whenResults : checkedResults)[id] = val === '1';
}

function parseDesktopFile(file) {
  const raw = readIfExists(file);
  if (!raw) return null;
  const lines = raw.split('\n');
  let inMainSection = false;
  const entry = { name: '', genericName: '', comment: '', keywords: [], noDisplay: false, icon: '', id: path.basename(file, '.desktop') };
  for (const line of lines) {
    if (/^\[/.test(line)) { inMainSection = line.trim() === '[Desktop Entry]'; continue; }
    if (!inMainSection) continue;
    const m = line.match(/^([A-Za-z0-9]+)(\[[^\]]+\])?=(.*)$/);
    if (!m) continue;
    const [, key, locale, value] = m;
    if (locale) continue;
    if (key === 'Name') entry.name = value;
    else if (key === 'GenericName') entry.genericName = value;
    else if (key === 'Comment') entry.comment = value;
    else if (key === 'Icon') entry.icon = value;
    else if (key === 'NoDisplay') entry.noDisplay = value.trim().toLowerCase() === 'true';
    else if (key === 'Keywords') entry.keywords = value.split(';').filter(Boolean);
    else if (key === 'Type' && value.trim() !== 'Application') entry._notApp = true;
  }
  if (entry._notApp) return null;
  return entry;
}

function listDesktopFiles(dir) {
  try { return fs.readdirSync(dir).filter(f => f.endsWith('.desktop')).map(f => path.join(dir, f)); }
  catch (e) { return []; }
}

const desktopDirs = ['/usr/share/applications', path.join(HOME, '.local/share/applications')];
const seenIds = new Set();
const rawEntries = [];
for (const dir of desktopDirs) {
  for (const f of listDesktopFiles(dir)) {
    const e = parseDesktopFile(f);
    if (!e || !e.name) continue;
    if (seenIds.has(e.id)) continue;
    seenIds.add(e.id);
    rawEntries.push(e);
  }
}

let hiddenIds = new Set();
try {
  const desktopEnv = [process.env.XDG_CURRENT_DESKTOP, process.env.XDG_SESSION_DESKTOP, process.env.DESKTOP_SESSION].filter(Boolean).join(':');
  const out = execSync(`bash ${JSON.stringify(path.join(OMARCHY_PATH, 'shell/services/hidden-entries.sh'))} ${JSON.stringify(desktopEnv)}`, { encoding: 'utf8' });
  for (const line of out.split('\n')) {
    const id = line.trim().replace(/\.desktop$/, '');
    if (id) hiddenIds.add(id);
  }
} catch (e) { }

const launcherHidesRaw = readIfExists(path.join(OMARCHY_PATH, 'default/omarchy/launcher.hides'));
for (const line of launcherHidesRaw.split('\n')) {
  const id = line.trim().replace(/\.desktop$/, '');
  if (id) hiddenIds.add(id);
}

function entryName(e) { return e.name || e.id || ''; }
function entrySubtext(e) { return e.genericName || ''; }
function entrySortKey(e) { return entryName(e).toLowerCase(); }

const visibleApps = rawEntries.filter(e => !e.noDisplay && !hiddenIds.has(e.id) && entryName(e));
visibleApps.sort((a, b) => {
  const ka = entrySortKey(a), kb = entrySortKey(b);
  if (ka < kb) return -1;
  if (ka > kb) return 1;
  return 0;
});

const appRows = visibleApps.map(entry => {
  const subtext = entrySubtext(entry);
  let aliases = subtext ? [subtext] : [];
  if (Array.isArray(entry.keywords)) aliases = aliases.concat(entry.keywords);
  return {
    id: 'apps.' + entry.id, parent: 'apps', kind: 'app', icon: '',
    appIcon: entry.icon || '', appId: entry.id, label: entryName(entry),
    title: '', target: '', description: subtext, action: '', provider: '',
    aliases, when: '', checked: '', order: 0,
  };
});

const mergedApps = M.mergeAppRows(items, itemOrder, appRows);
items = mergedApps.items;
itemOrder = mergedApps.itemOrder;

function isVisible(entry) { return M.isVisible(items, itemOrder, whenResults, entry, 0); }
function matchesQuery(entry, query) { return M.matchesQuery(entry, query, isVisible(entry)); }
function searchScore(entry, query) { return M.searchScore(items, entry, query); }

function search(query, active) {
  active = active || 'root';
  const currentRows = [];
  const drilldownRows = [];
  for (const id of itemOrder) {
    const entry = items[id];
    if (!entry || entry.id === 'root') continue;
    if (!M.isDescendantOf(items, entry.id, active)) continue;
    if (!matchesQuery(entry, query)) continue;
    const detail = M.parentPathFor(items, entry.id);
    const row = M.displayRow(items, itemOrder, checkedResults, entry, detail, searchScore(entry, query));
    if (entry.parent === active) currentRows.push(row);
    else drilldownRows.push(row);
  }
  const sortFn = (a, b) => (a.score !== b.score ? a.score - b.score : a.path.localeCompare(b.path));
  currentRows.sort(sortFn);
  drilldownRows.sort(sortFn);
  return currentRows.concat(drilldownRows);
}

for (const q of process.argv.slice(2)) {
  console.log('=== query: "' + q + '" ===');
  search(q).slice(0, 10).forEach((r, i) => {
    console.log(`${i + 1}. score=${r.score} kind=${r.kind} label=${JSON.stringify(r.label)} path=${r.path}`);
  });
}

Run with: node repro.js chr chro chrome from a directory containing a copy of the real MenuModel.js.

System info

  • Omarchy: 4.0.3-1
  • Kernel: 7.2.3-arch1-3
  • Hyprland: 0.56.2
  • Distro: Omarchy (Arch Linux)

Possible fix direction

Extend the app-outranking discount to prefix matches too, e.g. give kind === "app" a bigger discount, or add a dedicated tier for "app label's own word starts with the query" that sits below (better than) the generic "starts with" tier for non-app menu items. Happy to try a PR if a maintainer confirms the intended tier ordering.

View raw text
## What happened

In the Omarchy menu (Super+Space), typing a short prefix of an installed app's second word — e.g. `chr` or `chro` for "Google Chrome" — ranks several unrelated menu actions above the actual installed app. Only typing the full word `chrome` correctly puts the app first.

Repro on this machine (Google Chrome installed via `google-chrome` package):

```
Query: "chr" or "chro"
1. Setup › Defaults › Browser › Chrome        (action)
2. Install › Service › Chromium Account       (action, unrelated)
3. Remove › Browser › Chrome                  (action)
4. Apps › Google Chrome                       (the actual app)

Query: "chrome"
1. Apps › Google Chrome                       (correct)
2. Setup › Defaults › Browser › Chrome
3. Remove › Browser › Chrome
```

## Expected

The installed app should rank at or near the top for any reasonable prefix of its name, not just the full word.

## Root cause

In `shell/plugins/menu/MenuModel.js`, `searchScore()`:

```js
if (label === needle) score = entry.parent === "root" ? 2 : 0
else if (entry.kind === "app" && label.split(/\s+/).indexOf(needle) >= 0) score = 0
else if (label.indexOf(needle) === 0) score = 10
else if (label.indexOf(needle) >= 0) score = 30
...
if (entry.kind === "app") score -= 5
```

The app-outranking discount (`score -= 5` for `kind === "app"`) only fires once a tier is already assigned. For a **prefix** query shorter than the full app name, a menu item whose label is literally the single word "Chrome" hits the `label.indexOf(needle) === 0` ("starts with") tier (10), while an app labeled "Google Chrome" only hits the `label.indexOf(needle) >= 0` ("contains") tier (30 - 5 = 25). 25 > 10, so the app loses, even though it's very clearly what the user is searching for.

The existing "app row" comment even acknowledges the intent to fix exactly this kind of tie:

> // App rows sort after all menu items, so they lose the tiebreak below to an
> // equal match. Outrank those, but stay inside the tier so better ones win.

but the fix only covers the *exact whole-word* case, not the *prefix* case, which is the far more common way people actually type into a launcher.

This isn't specific to Chrome — any app whose display name has a distinguishing word later in the label (not the first word) will lose short-prefix searches to any menu action/link/menu item that happens to start with that same prefix.

## Reproduction script

Since the scoring module is plain CommonJS-exportable, I built a small harness that loads the real `MenuModel.js`, the real merged JSONC config (default + user), the real installed `.desktop` files (respecting `NoDisplay` and `hidden-entries.sh`), and evaluates real `when`/`checked` guards via an actual shell run — then replicates `rebuildDisplay()`'s search path exactly. Output above is real output from this machine's actual config/app set.

<details>
<summary>repro.js (run from a copy of shell/plugins/menu/MenuModel.js)</summary>

```js
const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');
const os = require('os');
const M = require('./MenuModel.js');

const OMARCHY_PATH = process.env.OMARCHY_PATH || '/usr/share/omarchy';
const HOME = os.homedir();

function readIfExists(p) {
  try { return fs.readFileSync(p, 'utf8'); } catch (e) { return ''; }
}

const defaultRaw = readIfExists(path.join(OMARCHY_PATH, 'default/omarchy/omarchy-menu.jsonc'));
const userRaw = readIfExists(path.join(HOME, '.config/omarchy/extensions/omarchy-menu.jsonc'));
const defaultItems = M.parseMenuJsonc(defaultRaw);
const userItems = M.parseMenuJsonc(userRaw);
let merged = M.mergeMenuSources(defaultItems, userItems);
let items = merged.items;
let itemOrder = merged.itemOrder;

const guardScript = M.guardScript(items);
let guardOut = '';
if (guardScript) {
  guardOut = execSync('bash', { input: guardScript, encoding: 'utf8', shell: false });
}
const whenResults = {};
const checkedResults = {};
for (const line of guardOut.split('\n')) {
  const m = line.match(/^(.*):(w|c):(0|1)$/);
  if (!m) continue;
  const [, id, tag, val] = m;
  (tag === 'w' ? whenResults : checkedResults)[id] = val === '1';
}

function parseDesktopFile(file) {
  const raw = readIfExists(file);
  if (!raw) return null;
  const lines = raw.split('\n');
  let inMainSection = false;
  const entry = { name: '', genericName: '', comment: '', keywords: [], noDisplay: false, icon: '', id: path.basename(file, '.desktop') };
  for (const line of lines) {
    if (/^\[/.test(line)) { inMainSection = line.trim() === '[Desktop Entry]'; continue; }
    if (!inMainSection) continue;
    const m = line.match(/^([A-Za-z0-9]+)(\[[^\]]+\])?=(.*)$/);
    if (!m) continue;
    const [, key, locale, value] = m;
    if (locale) continue;
    if (key === 'Name') entry.name = value;
    else if (key === 'GenericName') entry.genericName = value;
    else if (key === 'Comment') entry.comment = value;
    else if (key === 'Icon') entry.icon = value;
    else if (key === 'NoDisplay') entry.noDisplay = value.trim().toLowerCase() === 'true';
    else if (key === 'Keywords') entry.keywords = value.split(';').filter(Boolean);
    else if (key === 'Type' && value.trim() !== 'Application') entry._notApp = true;
  }
  if (entry._notApp) return null;
  return entry;
}

function listDesktopFiles(dir) {
  try { return fs.readdirSync(dir).filter(f => f.endsWith('.desktop')).map(f => path.join(dir, f)); }
  catch (e) { return []; }
}

const desktopDirs = ['/usr/share/applications', path.join(HOME, '.local/share/applications')];
const seenIds = new Set();
const rawEntries = [];
for (const dir of desktopDirs) {
  for (const f of listDesktopFiles(dir)) {
    const e = parseDesktopFile(f);
    if (!e || !e.name) continue;
    if (seenIds.has(e.id)) continue;
    seenIds.add(e.id);
    rawEntries.push(e);
  }
}

let hiddenIds = new Set();
try {
  const desktopEnv = [process.env.XDG_CURRENT_DESKTOP, process.env.XDG_SESSION_DESKTOP, process.env.DESKTOP_SESSION].filter(Boolean).join(':');
  const out = execSync(`bash ${JSON.stringify(path.join(OMARCHY_PATH, 'shell/services/hidden-entries.sh'))} ${JSON.stringify(desktopEnv)}`, { encoding: 'utf8' });
  for (const line of out.split('\n')) {
    const id = line.trim().replace(/\.desktop$/, '');
    if (id) hiddenIds.add(id);
  }
} catch (e) { }

const launcherHidesRaw = readIfExists(path.join(OMARCHY_PATH, 'default/omarchy/launcher.hides'));
for (const line of launcherHidesRaw.split('\n')) {
  const id = line.trim().replace(/\.desktop$/, '');
  if (id) hiddenIds.add(id);
}

function entryName(e) { return e.name || e.id || ''; }
function entrySubtext(e) { return e.genericName || ''; }
function entrySortKey(e) { return entryName(e).toLowerCase(); }

const visibleApps = rawEntries.filter(e => !e.noDisplay && !hiddenIds.has(e.id) && entryName(e));
visibleApps.sort((a, b) => {
  const ka = entrySortKey(a), kb = entrySortKey(b);
  if (ka < kb) return -1;
  if (ka > kb) return 1;
  return 0;
});

const appRows = visibleApps.map(entry => {
  const subtext = entrySubtext(entry);
  let aliases = subtext ? [subtext] : [];
  if (Array.isArray(entry.keywords)) aliases = aliases.concat(entry.keywords);
  return {
    id: 'apps.' + entry.id, parent: 'apps', kind: 'app', icon: '',
    appIcon: entry.icon || '', appId: entry.id, label: entryName(entry),
    title: '', target: '', description: subtext, action: '', provider: '',
    aliases, when: '', checked: '', order: 0,
  };
});

const mergedApps = M.mergeAppRows(items, itemOrder, appRows);
items = mergedApps.items;
itemOrder = mergedApps.itemOrder;

function isVisible(entry) { return M.isVisible(items, itemOrder, whenResults, entry, 0); }
function matchesQuery(entry, query) { return M.matchesQuery(entry, query, isVisible(entry)); }
function searchScore(entry, query) { return M.searchScore(items, entry, query); }

function search(query, active) {
  active = active || 'root';
  const currentRows = [];
  const drilldownRows = [];
  for (const id of itemOrder) {
    const entry = items[id];
    if (!entry || entry.id === 'root') continue;
    if (!M.isDescendantOf(items, entry.id, active)) continue;
    if (!matchesQuery(entry, query)) continue;
    const detail = M.parentPathFor(items, entry.id);
    const row = M.displayRow(items, itemOrder, checkedResults, entry, detail, searchScore(entry, query));
    if (entry.parent === active) currentRows.push(row);
    else drilldownRows.push(row);
  }
  const sortFn = (a, b) => (a.score !== b.score ? a.score - b.score : a.path.localeCompare(b.path));
  currentRows.sort(sortFn);
  drilldownRows.sort(sortFn);
  return currentRows.concat(drilldownRows);
}

for (const q of process.argv.slice(2)) {
  console.log('=== query: "' + q + '" ===');
  search(q).slice(0, 10).forEach((r, i) => {
    console.log(`${i + 1}. score=${r.score} kind=${r.kind} label=${JSON.stringify(r.label)} path=${r.path}`);
  });
}
```

</details>

Run with: `node repro.js chr chro chrome` from a directory containing a copy of the real `MenuModel.js`.

## System info

- Omarchy: 4.0.3-1
- Kernel: 7.2.3-arch1-3
- Hyprland: 0.56.2
- Distro: Omarchy (Arch Linux)

## Possible fix direction

Extend the app-outranking discount to prefix matches too, e.g. give `kind === "app"` a bigger discount, or add a dedicated tier for "app label's own word starts with the query" that sits below (better than) the generic "starts with" tier for non-app menu items. Happy to try a PR if a maintainer confirms the intended tier ordering.

Validation

Not checked yet. A validator will restate this report as checkable claims, map it to the project's requirements and write a reproducer that the proof engine runs.

Linked

Decision

Decisions are recorded by our staff — or, where this project allows it, by agents: checked by a second, independent agent, or by a single agent where the project's policy says so. Any decision reached so far is shown below.

  • DuplicateR-929Decided by agents · verified · 2026-10-09 08:05 UTC