Skip to content

Instantly share code, notes, and snippets.

@Kr328
Last active August 15, 2026 15:31
Show Gist options
  • Select an option

  • Save Kr328/947d88bedc50c77e31fd9cece2c8b8a2 to your computer and use it in GitHub Desktop.

Select an option

Save Kr328/947d88bedc50c77e31fd9cece2c8b8a2 to your computer and use it in GitHub Desktop.
A desktop entry hider for Linux desktop.
#!/usr/bin/gjs -m
import Gio from 'gi://Gio';
import GioUnix from 'gi://GioUnix?version=2.0';
import GLib from 'gi://GLib';
import Gtk from 'gi://Gtk?version=4.0';
import Pango from 'gi://Pango';
const APPLICATION_ID = 'com.github.kr328.DesktopEntryHider';
const GNOME_SHELL_SCHEMA = 'org.gnome.shell';
const APP_PICKER_LAYOUT_KEY = 'app-picker-layout';
const SYSTEM_APPLICATIONS_DIR = '/usr/share/applications';
const USER_APPLICATIONS_DIR = GLib.build_filenamev([
GLib.get_home_dir(),
'.local',
'share',
'applications',
]);
const GENERATED_MARKER = '# Generated by desktop-entry-hider; do not edit.';
const textDecoder = new TextDecoder('utf-8');
const textEncoder = new TextEncoder();
function pathFor(directory, name) {
return GLib.build_filenamev([directory, name]);
}
function isNotFound(error) {
return error instanceof GLib.Error &&
error.matches(Gio.io_error_quark(), Gio.IOErrorEnum.NOT_FOUND);
}
function queryFileType(file) {
try {
const info = file.query_info(
Gio.FILE_ATTRIBUTE_STANDARD_TYPE,
Gio.FileQueryInfoFlags.NOFOLLOW_SYMLINKS,
null
);
return info.get_file_type();
} catch (error) {
if (isNotFound(error))
return null;
throw error;
}
}
function readText(file) {
const [, contents] = file.load_contents(null);
return textDecoder.decode(contents);
}
function firstLine(text) {
const newline = text.indexOf('\n');
const line = newline < 0 ? text : text.slice(0, newline);
return line.endsWith('\r') ? line.slice(0, -1) : line;
}
function inspectUserFile(name) {
const file = Gio.File.new_for_path(pathFor(USER_APPLICATIONS_DIR, name));
const type = queryFileType(file);
if (type === null)
return { state: 'missing', file };
if (type !== Gio.FileType.REGULAR)
return { state: 'conflict', file };
try {
return {
state: firstLine(readText(file)) === GENERATED_MARKER
? 'managed'
: 'conflict',
file,
};
} catch (_error) {
return { state: 'conflict', file };
}
}
function transformDesktopFile(sourceText) {
let text = sourceText;
const sourceNewline = text.includes('\r\n') ? '\r\n' : '\n';
if (firstLine(text) === GENERATED_MARKER) {
const markerEnd = text.indexOf('\n');
text = markerEnd < 0 ? '' : text.slice(markerEnd + 1);
}
const hadFinalNewline = text.endsWith('\n');
const lines = text.split(/\r?\n/);
if (hadFinalNewline)
lines.pop();
const groupStart = lines.findIndex(line => line.trim() === '[Desktop Entry]');
if (groupStart < 0)
throw new Error('The file is missing a [Desktop Entry] group');
let groupEnd = lines.length;
for (let index = groupStart + 1; index < lines.length; index++) {
if (/^\s*\[.*\]\s*$/.test(lines[index])) {
groupEnd = index;
break;
}
}
const hiddenKeys = new Map([
['Hidden', false],
['NoDisplay', false],
]);
for (let index = groupStart + 1; index < groupEnd;) {
const match = lines[index].match(/^\s*(Hidden|NoDisplay)\s*=/);
if (match !== null) {
const key = match[1];
if (!hiddenKeys.get(key)) {
lines[index] = `${key}=true`;
hiddenKeys.set(key, true);
index++;
} else {
lines.splice(index, 1);
groupEnd--;
}
} else {
index++;
}
}
const missingKeys = [...hiddenKeys]
.filter(([, found]) => !found)
.map(([key]) => `${key}=true`);
lines.splice(groupStart + 1, 0, ...missingKeys);
let result = `${GENERATED_MARKER}${sourceNewline}${lines.join(sourceNewline)}`;
if (hadFinalNewline)
result += sourceNewline;
return result;
}
function ensureUserApplicationsDirectory() {
if (GLib.mkdir_with_parents(USER_APPLICATIONS_DIR, 0o700) !== 0)
throw new Error(`Unable to create directory: ${USER_APPLICATIONS_DIR}`);
}
function replaceManagedFile(file, text, expectedState) {
const name = file.get_basename();
const current = inspectUserFile(name).state;
if (current !== expectedState && !(expectedState === 'missing' && current === 'managed'))
throw new Error(`Refusing to overwrite a file not generated by this application: ${name}`);
file.replace_contents(
textEncoder.encode(text),
null,
false,
Gio.FileCreateFlags.REPLACE_DESTINATION,
null
);
}
function hideEntry(name) {
const source = Gio.File.new_for_path(pathFor(SYSTEM_APPLICATIONS_DIR, name));
if (queryFileType(source) !== Gio.FileType.REGULAR)
throw new Error(`The system desktop entry is missing or is not a regular file: ${name}`);
const targetInfo = inspectUserFile(name);
if (targetInfo.state === 'conflict')
throw new Error(`A user file with the same name exists and was not generated by this application: ${name}`);
const transformed = transformDesktopFile(readText(source));
ensureUserApplicationsDirectory();
replaceManagedFile(targetInfo.file, transformed, targetInfo.state);
}
function unhideEntry(name) {
const targetInfo = inspectUserFile(name);
if (targetInfo.state === 'missing')
return;
if (targetInfo.state !== 'managed')
throw new Error(`Refusing to delete a file not generated by this application: ${name}`);
targetInfo.file.delete(null);
}
function enumerateDesktopFiles(directory) {
const root = Gio.File.new_for_path(directory);
let enumerator;
try {
enumerator = root.enumerate_children(
`${Gio.FILE_ATTRIBUTE_STANDARD_NAME},${Gio.FILE_ATTRIBUTE_STANDARD_TYPE}`,
Gio.FileQueryInfoFlags.NOFOLLOW_SYMLINKS,
null
);
} catch (error) {
if (isNotFound(error))
return [];
throw error;
}
const names = [];
try {
for (; ;) {
const info = enumerator.next_file(null);
if (info === null)
break;
const name = info.get_name();
if (info.get_file_type() === Gio.FileType.REGULAR && name.endsWith('.desktop'))
names.push(name);
}
} finally {
enumerator.close(null);
}
return names;
}
function desktopInfoFor(path) {
try {
const info = GioUnix.DesktopAppInfo.new_from_filename(path);
if (info === null || info.get_string('Type') !== 'Application')
return null;
return info;
} catch (_error) {
return null;
}
}
function entryFromInfo(name, info, state, sourceExists, listedWhenShown) {
const displayName = info?.get_display_name() ||
info?.get_name() || name.replace(/\.desktop$/, '');
return {
name,
displayName,
description: info?.get_description() || '',
icon: info?.get_icon() || null,
state,
sourceExists,
listedWhenShown,
};
}
function normalizeSearchText(text) {
return text.trim().toLocaleLowerCase();
}
function entryMatchesSearch(entry, normalizedQuery) {
if (normalizedQuery === '')
return true;
const searchableText = normalizeSearchText(
`${entry.displayName}\n${entry.description}`
);
return searchableText.includes(normalizedQuery);
}
function desktopSessionIsGnome(value) {
if (value === null || value === '')
return false;
return value.split(':').some(desktop => {
const normalized = desktop.trim().toLocaleLowerCase();
return normalized === 'gnome' || normalized.startsWith('gnome-');
});
}
function isGnomeSession() {
const currentDesktop = GLib.getenv('XDG_CURRENT_DESKTOP');
if (currentDesktop !== null && currentDesktop !== '')
return desktopSessionIsGnome(currentDesktop);
return desktopSessionIsGnome(GLib.getenv('XDG_SESSION_DESKTOP')) ||
desktopSessionIsGnome(GLib.getenv('DESKTOP_SESSION'));
}
function createAppPickerSettings() {
if (!isGnomeSession())
return null;
try {
const source = Gio.SettingsSchemaSource.get_default();
const schema = source?.lookup(GNOME_SHELL_SCHEMA, true) || null;
if (schema === null || !schema.has_key(APP_PICKER_LAYOUT_KEY))
return null;
return new Gio.Settings({ settings_schema: schema });
} catch (_error) {
return null;
}
}
function loadEntries() {
const entries = new Map();
for (const name of enumerateDesktopFiles(SYSTEM_APPLICATIONS_DIR)) {
const systemPath = pathFor(SYSTEM_APPLICATIONS_DIR, name);
const info = desktopInfoFor(systemPath);
if (info === null)
continue;
const targetState = inspectUserFile(name).state;
const listedWhenShown = info.should_show();
if (listedWhenShown || targetState === 'managed') {
entries.set(name, entryFromInfo(
name,
info,
targetState,
true,
listedWhenShown
));
}
}
for (const name of enumerateDesktopFiles(USER_APPLICATIONS_DIR)) {
const targetState = inspectUserFile(name).state;
if (targetState !== 'managed' || entries.has(name))
continue;
const systemPath = pathFor(SYSTEM_APPLICATIONS_DIR, name);
const sourceExists = queryFileType(Gio.File.new_for_path(systemPath)) ===
Gio.FileType.REGULAR;
const info = (sourceExists && desktopInfoFor(systemPath)) ||
desktopInfoFor(pathFor(USER_APPLICATIONS_DIR, name));
const listedWhenShown = sourceExists && info !== null && info.should_show();
entries.set(name, entryFromInfo(
name,
info,
targetState,
sourceExists,
listedWhenShown
));
}
return [...entries.values()].sort((left, right) => {
const byDisplayName = GLib.utf8_collate(left.displayName, right.displayName);
return byDisplayName || left.name.localeCompare(right.name);
});
}
function managedUserFiles() {
return enumerateDesktopFiles(USER_APPLICATIONS_DIR)
.filter(name => inspectUserFile(name).state === 'managed');
}
function synchronizeManagedFiles() {
const result = { updated: 0, deleted: 0, failed: [] };
for (const name of managedUserFiles()) {
const source = Gio.File.new_for_path(pathFor(SYSTEM_APPLICATIONS_DIR, name));
const target = Gio.File.new_for_path(pathFor(USER_APPLICATIONS_DIR, name));
try {
const sourceType = queryFileType(source);
if (sourceType === null) {
if (inspectUserFile(name).state === 'managed') {
target.delete(null);
result.deleted++;
}
continue;
}
if (sourceType !== Gio.FileType.REGULAR)
throw new Error('The system source is not a regular file');
const transformed = transformDesktopFile(readText(source));
replaceManagedFile(target, transformed, 'managed');
result.updated++;
} catch (error) {
result.failed.push(`${name}: ${error.message}`);
}
}
return result;
}
function clearManagedFiles() {
const result = { deleted: 0, failed: [] };
for (const name of managedUserFiles()) {
try {
const targetInfo = inspectUserFile(name);
if (targetInfo.state !== 'managed')
throw new Error('The file state has changed');
targetInfo.file.delete(null);
result.deleted++;
} catch (error) {
result.failed.push(`${name}: ${error.message}`);
}
}
return result;
}
class IconHiderWindow {
constructor(application) {
this._busy = false;
this._hasBeenActive = false;
this._scrollRestoreSourceId = 0;
this._entries = [];
this._entryForChild = new Map();
this._normalizedQuery = '';
this._appPickerSettings = createAppPickerSettings();
this._window = new Gtk.ApplicationWindow({
application,
title: 'Desktop Entry Hider',
default_width: 720,
default_height: 640,
});
const headerBar = new Gtk.HeaderBar();
const menu = new Gio.Menu();
menu.append('Refresh', 'app.refresh');
menu.append('Sync with System', 'app.sync');
menu.append('Clear All Hidden Entries', 'app.clear');
menu.append('Reset AppPicker Layout', 'app.reset-app-picker-layout');
headerBar.pack_end(new Gtk.MenuButton({
icon_name: 'open-menu-symbolic',
menu_model: menu,
tooltip_text: 'Menu',
}));
this._window.set_titlebar(headerBar);
const root = new Gtk.Box({
orientation: Gtk.Orientation.VERTICAL,
spacing: 0,
});
this._window.set_child(root);
this._searchEntry = new Gtk.SearchEntry({
placeholder_text: 'Search applications…',
hexpand: true,
margin_start: 12,
margin_end: 12,
margin_top: 12,
margin_bottom: 8,
});
root.append(this._searchEntry);
this._flowBox = new Gtk.FlowBox({
selection_mode: Gtk.SelectionMode.NONE,
orientation: Gtk.Orientation.HORIZONTAL,
min_children_per_line: 1,
max_children_per_line: 4,
row_spacing: 4,
margin_start: 12,
margin_end: 12,
margin_top: 4,
margin_bottom: 4,
valign: Gtk.Align.START,
});
this._flowBox.set_filter_func(child => {
const entry = this._entryForChild.get(child);
return entry !== undefined &&
entryMatchesSearch(entry, this._normalizedQuery);
});
this._scroller = new Gtk.ScrolledWindow({
hscrollbar_policy: Gtk.PolicyType.NEVER,
vscrollbar_policy: Gtk.PolicyType.AUTOMATIC,
vexpand: true,
child: this._flowBox,
});
this._emptyState = new Gtk.Label({
label: 'No desktop entries to display',
margin_start: 24,
margin_end: 24,
margin_top: 32,
margin_bottom: 32,
valign: Gtk.Align.CENTER,
});
this._emptyState.add_css_class('dim-label');
this._contentStack = new Gtk.Stack({ vexpand: true });
this._contentStack.add_named(this._scroller, 'content');
this._contentStack.add_named(this._emptyState, 'empty');
root.append(this._contentStack);
this._status = new Gtk.Label({
xalign: 0,
ellipsize: Pango.EllipsizeMode.END,
margin_start: 12,
margin_end: 12,
margin_top: 8,
margin_bottom: 8,
label: '',
});
this._status.add_css_class('dim-label');
root.append(this._status);
this._installSearch();
this._installActions(application);
this._installFocusRefresh();
this.reload();
}
present() {
this._window.present();
}
_installSearch() {
this._searchEntry.connect('search-changed', () => {
this._normalizedQuery = normalizeSearchText(this._searchEntry.text);
this._flowBox.invalidate_filter();
this._updateContentState();
this._cancelScrollRestore();
this._restoreScrollPosition(this._scroller.vadjustment.lower);
});
this._searchEntry.connect('stop-search', () => {
if (this._searchEntry.text !== '')
this._searchEntry.text = '';
});
}
_installActions(application) {
const refreshAction = new Gio.SimpleAction({ name: 'refresh' });
refreshAction.connect('activate', () => {
if (!this._busy)
this.reload({ preserveScroll: true });
});
application.add_action(refreshAction);
const syncAction = new Gio.SimpleAction({ name: 'sync' });
syncAction.connect('activate', () => this._runBatch('sync'));
application.add_action(syncAction);
const clearAction = new Gio.SimpleAction({ name: 'clear' });
clearAction.connect('activate', () => this._runBatch('clear'));
application.add_action(clearAction);
const resetAppPickerAction = new Gio.SimpleAction({
name: 'reset-app-picker-layout',
enabled: this._appPickerSettings !== null,
});
resetAppPickerAction.connect('activate', () =>
this._confirmResetAppPickerLayout()
);
application.add_action(resetAppPickerAction);
}
_confirmResetAppPickerLayout() {
const dialog = new Gtk.AlertDialog({
message: 'Reset AppPicker Layout?',
detail: 'This will clear the custom application grid and folder layout, and restore the GNOME default layout.',
buttons: ['Cancel', 'Reset'],
cancel_button: 0,
default_button: 0,
});
dialog.choose(this._window, null, (source, result) => {
try {
if (source.choose_finish(result) === 1)
this._resetAppPickerLayout();
} catch (error) {
this._showError('Unable to Reset AppPicker Layout', error.message);
}
});
}
_resetAppPickerLayout() {
try {
if (this._appPickerSettings === null)
throw new Error('GNOME AppPicker settings are unavailable');
this._appPickerSettings.reset(APP_PICKER_LAYOUT_KEY);
this._showMessage(
'AppPicker Layout Reset',
'The default GNOME application grid and folder layout has been restored. The change will take effect after you log in again.'
);
} catch (error) {
this._showError('Unable to Reset AppPicker Layout', error.message);
}
}
_installFocusRefresh() {
this._window.connect('notify::is-active', () => {
if (!this._window.is_active)
return;
if (this._hasBeenActive && !this._busy)
this.reload({ preserveScroll: true });
this._hasBeenActive = true;
});
}
_clearTiles() {
for (; ;) {
const child = this._flowBox.get_first_child();
if (child === null)
break;
this._flowBox.remove(child);
}
this._entryForChild.clear();
}
_updateContentState() {
const total = this._entries.length;
const matches = this._entries.filter(entry =>
entryMatchesSearch(entry, this._normalizedQuery)
).length;
if (total === 0) {
this._emptyState.label = 'No desktop entries to display';
this._contentStack.visible_child_name = 'empty';
} else if (matches === 0) {
this._emptyState.label = 'No matching desktop entries';
this._contentStack.visible_child_name = 'empty';
} else {
this._contentStack.visible_child_name = 'content';
}
this._status.label = this._normalizedQuery === ''
? `${total} entries`
: `${matches} of ${total} entries`;
}
_cancelScrollRestore() {
if (this._scrollRestoreSourceId === 0)
return;
GLib.Source.remove(this._scrollRestoreSourceId);
this._scrollRestoreSourceId = 0;
}
_restoreScrollPosition(value) {
this._scrollRestoreSourceId = GLib.idle_add(
GLib.PRIORITY_DEFAULT_IDLE,
() => {
this._scrollRestoreSourceId = 0;
const adjustment = this._scroller.vadjustment;
const maximum = Math.max(
adjustment.lower,
adjustment.upper - adjustment.page_size
);
adjustment.value = Math.min(
Math.max(value, adjustment.lower),
maximum
);
return GLib.SOURCE_REMOVE;
}
);
}
reload({ preserveScroll = false } = {}) {
const scrollPosition = preserveScroll
? this._scroller.vadjustment.value
: null;
this._cancelScrollRestore();
this._clearTiles();
try {
const entries = loadEntries();
this._entries = entries;
for (const entry of entries)
this._flowBox.append(this._createTile(entry));
this._flowBox.invalidate_filter();
this._updateContentState();
} catch (error) {
this._entries = [];
this._contentStack.visible_child_name = 'empty';
this._emptyState.label = 'Unable to load desktop entries';
this._status.label = `Failed to load desktop entries: ${error.message}`;
this._showError('Load Failed', error.message);
}
if (scrollPosition !== null)
this._restoreScrollPosition(scrollPosition);
}
_createTile(entry) {
const content = new Gtk.Box({
orientation: Gtk.Orientation.HORIZONTAL,
spacing: 12,
margin_start: 12,
margin_end: 12,
margin_top: 10,
margin_bottom: 10,
});
const image = new Gtk.Image({ pixel_size: 40 });
if (entry.icon !== null)
image.set_from_gicon(entry.icon);
else
image.set_from_icon_name('application-x-executable-symbolic');
content.append(image);
const labels = new Gtk.Box({
orientation: Gtk.Orientation.VERTICAL,
spacing: 3,
hexpand: true,
valign: Gtk.Align.CENTER,
});
content.append(labels);
const nameLabel = new Gtk.Label({
label: entry.displayName,
xalign: 0,
ellipsize: Pango.EllipsizeMode.END,
max_width_chars: 28,
});
labels.append(nameLabel);
if (entry.description) {
const descriptionLabel = new Gtk.Label({
label: entry.description,
xalign: 0,
wrap: true,
wrap_mode: Pango.WrapMode.WORD_CHAR,
lines: 2,
max_width_chars: 32,
});
descriptionLabel.add_css_class('dim-label');
labels.append(descriptionLabel);
}
if (!entry.sourceExists) {
const missingLabel = new Gtk.Label({
label: 'The system source no longer exists; this entry will be removed during synchronization',
xalign: 0,
wrap: true,
wrap_mode: Pango.WrapMode.WORD_CHAR,
lines: 2,
max_width_chars: 32,
});
missingLabel.add_css_class('warning');
labels.append(missingLabel);
}
if (entry.state === 'conflict') {
const warning = new Gtk.Image({
icon_name: 'dialog-warning-symbolic',
tooltip_text: 'A user file with the same name was not generated by this application and cannot be managed',
});
content.append(warning);
}
const toggle = new Gtk.Switch({
active: entry.state !== 'managed',
sensitive: entry.state !== 'conflict' &&
(entry.sourceExists || entry.state === 'managed'),
valign: Gtk.Align.CENTER,
tooltip_text: entry.state === 'conflict'
? 'A user file with the same name was not generated by this application and will not be overwritten'
: 'Show or hide this desktop entry',
});
content.append(toggle);
let changingProgrammatically = false;
toggle.connect('notify::active', () => {
if (changingProgrammatically || this._busy)
return;
const desired = toggle.active;
toggle.sensitive = false;
try {
if (desired)
unhideEntry(entry.name);
else
hideEntry(entry.name);
this._status.label = desired
? `Shown: ${entry.displayName}`
: `Hidden: ${entry.displayName}`;
entry.state = desired ? 'missing' : 'managed';
if (desired && !entry.listedWhenShown) {
this.reload({ preserveScroll: true });
} else {
toggle.sensitive = entry.state !== 'conflict' &&
(entry.sourceExists || entry.state === 'managed');
if (this._normalizedQuery !== '')
this._updateContentState();
}
} catch (error) {
changingProgrammatically = true;
toggle.active = !desired;
changingProgrammatically = false;
toggle.sensitive = entry.state !== 'conflict' &&
(entry.sourceExists || entry.state === 'managed');
this._showError('Operation Failed', error.message);
}
});
const child = new Gtk.FlowBoxChild({ child: content });
this._entryForChild.set(child, entry);
return child;
}
_runBatch(kind) {
if (this._busy)
return;
this._busy = true;
this._flowBox.sensitive = false;
try {
if (kind === 'sync') {
const result = synchronizeManagedFiles();
const details = [
`Updated ${result.updated} entries and removed ${result.deleted} obsolete entries.`,
];
if (result.failed.length > 0)
details.push(`${result.failed.length} failed:\n${result.failed.join('\n')}`);
this._showMessage('Synchronization Complete', details.join('\n\n'));
} else {
const result = clearManagedFiles();
const details = [`Cleared ${result.deleted} hidden entries.`];
if (result.failed.length > 0)
details.push(`${result.failed.length} failed:\n${result.failed.join('\n')}`);
this._showMessage('Cleanup Complete', details.join('\n\n'));
}
} catch (error) {
this._showError(kind === 'sync' ? 'Synchronization Failed' : 'Cleanup Failed', error.message);
} finally {
this._busy = false;
this._flowBox.sensitive = true;
this.reload({ preserveScroll: true });
}
}
_showMessage(title, detail) {
const dialog = new Gtk.AlertDialog({ message: title, detail });
dialog.show(this._window);
}
_showError(title, detail) {
const dialog = new Gtk.AlertDialog({ message: title, detail });
dialog.show(this._window);
}
}
const application = new Gtk.Application({
application_id: APPLICATION_ID,
flags: Gio.ApplicationFlags.DEFAULT_FLAGS,
});
let mainWindow = null;
application.connect('activate', app => {
if (mainWindow === null)
mainWindow = new IconHiderWindow(app);
mainWindow.present();
});
application.run(ARGV);
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment