Skip to content

Commit 5cb26fb

Browse files
committed
feat: add Launchpad category folders sorting
Organize third-party apps into Launchpad folders by App Store category. Auto-detects categories via mdls metadata with manual overrides for apps without App Store data. Includes full reset option.
1 parent ab8b9e4 commit 5cb26fb

4 files changed

Lines changed: 315 additions & 2 deletions

File tree

README.md

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ git clone https://github.com/emylfy/macrift.git ~/.macrift && ~/.macrift/macrift
8282
| :-- | :--------------------- | :--------------------------------------------------------------------- |
8383
| ⚙️ | **System Tweaks** | Dock, Finder, Keyboard, Trackpad, Screenshots, Misc, Privacy |
8484
| 📦 | **Apps & Packages** | 7 Homebrew bundles, Mac App Store, Spotify, .brewbak backup |
85-
| 🎨 | **Customize** | Profile, Terminal, Shell, Editor, Claude Code, Dock Layout, Wallpapers |
85+
| 🎨 | **Customize** | Profile, Terminal, Shell, Editor, Claude Code, Dock Layout, Launchpad, Wallpapers |
8686
| 🛡️ | **Security & Privacy** | Security status, hostname, DNS benchmark, update control |
8787
| 🧹 | **Cleanup** | System cleanup via Mole — caches, logs, leftovers |
8888

@@ -192,6 +192,16 @@ Dock management via [dockutil](https://github.com/kcrawford/dockutil):
192192

193193
</details>
194194

195+
<details>
196+
<summary>🚀 Launchpad</summary>
197+
198+
Organize Launchpad apps into folders by App Store category:
199+
200+
- **Sort by category** — auto-detects app categories via metadata, creates folders (Developer Tools, Productivity, Utilities, etc.), merges small categories into Other
201+
- **Reset to default** — full Launchpad reset to factory layout
202+
203+
</details>
204+
195205
<details>
196206
<summary>🖼️ Wallpapers</summary>
197207

customize/launchpad.sh

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
#!/usr/bin/env bash
2+
# macrift — Launchpad layout management
3+
4+
_LP_SCRIPT="$MACRIFT_DIR/customize/launchpad_sort.py"
5+
6+
launchpad_menu() {
7+
crumb_push "Launchpad"
8+
while true; do
9+
clear
10+
11+
local choice
12+
choice=$(show_menu "Launchpad" \
13+
"Sort by category (folders)" \
14+
"---" \
15+
"Reset to default" \
16+
"Back")
17+
18+
case "$choice" in
19+
1) _lp_sort_by_category ;;
20+
2) _lp_reset ;;
21+
0) break ;;
22+
*) ;;
23+
esac
24+
done
25+
crumb_pop
26+
}
27+
28+
_lp_reset() {
29+
clear
30+
31+
if [[ "$MACRIFT_DRY_RUN" == true ]]; then
32+
log_info "Dry run — would reset Launchpad to defaults"
33+
wait_enter
34+
return
35+
fi
36+
37+
if ! confirm "Reset Launchpad to default layout?"; then return; fi
38+
39+
local lp_db darwin_dir
40+
darwin_dir=$(getconf DARWIN_USER_DIR)
41+
lp_db="${darwin_dir}com.apple.dock.launchpad/db/db"
42+
defaults write com.apple.dock ResetLaunchPad -bool true
43+
rm -f "$lp_db"
44+
killall Dock 2>/dev/null || true
45+
46+
log_ok "Launchpad reset to defaults"
47+
wait_enter
48+
}
49+
50+
_lp_sort_by_category() {
51+
clear
52+
53+
if ! command -v python3 &>/dev/null; then
54+
log_err "python3 required"
55+
wait_enter
56+
return
57+
fi
58+
59+
# Preview: get folder names and counts
60+
log_info "Scanning app categories..."
61+
local preview
62+
preview=$(python3 "$_LP_SCRIPT" preview 2>&1)
63+
64+
if [[ -z "$preview" ]]; then
65+
log_warn "No third-party apps found to organize"
66+
wait_enter
67+
return
68+
fi
69+
70+
echo ""
71+
log_info "Folders to create:"
72+
local folder_count=0
73+
while IFS='|' read -r cat_name count; do
74+
printf ' %s (%d apps)\n' "$cat_name" "$count"
75+
folder_count=$((folder_count + 1))
76+
done <<< "$preview"
77+
echo ""
78+
79+
if [[ "$MACRIFT_DRY_RUN" == true ]]; then
80+
log_info "Dry run — would create $folder_count folders"
81+
wait_enter
82+
return
83+
fi
84+
85+
if ! confirm "Create $folder_count category folders?"; then return; fi
86+
87+
local result
88+
result=$(python3 "$_LP_SCRIPT" apply 2>&1)
89+
90+
if [[ "$result" == OK* ]]; then
91+
local created
92+
created=$(echo "$result" | cut -d'|' -f2)
93+
log_ok "Launchpad sorted into $created category folders"
94+
else
95+
log_err "Failed: $result"
96+
fi
97+
98+
log_info "Reset: macrift > Customize > Launchpad > Reset to default"
99+
wait_enter
100+
}

customize/launchpad_sort.py

Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
#!/usr/bin/env python3
2+
"""Launchpad: create category folders from third-party apps.
3+
Called by macrift/customize/launchpad.sh — not standalone."""
4+
5+
import subprocess
6+
import sqlite3
7+
import os
8+
import uuid
9+
import sys
10+
from collections import Counter, defaultdict
11+
12+
CATEGORY_OVERRIDES = {
13+
"dev.zed.Zed": "Developer Tools",
14+
"com.rogueamoeba.soundsource": "Utilities",
15+
"com.crystalidea.macsfancontrol": "Utilities",
16+
"com.image-line.fl-cloud-plugins": "Music",
17+
"com.image-line.flstudio": "Music",
18+
"com.anthropic.claude-code-url-handler": "Developer Tools",
19+
"com.logi.pluginservice": "Utilities",
20+
}
21+
22+
MIN_CATEGORY_SIZE = 2
23+
24+
TRIGGERS_SQL = """
25+
CREATE TRIGGER update_items_order BEFORE UPDATE OF ordering ON items WHEN new.ordering > old.ordering AND 0 == (SELECT value FROM dbinfo WHERE key='ignore_items_update_triggers')
26+
BEGIN
27+
UPDATE dbinfo SET value=1 WHERE key='ignore_items_update_triggers';
28+
UPDATE items SET ordering = ordering - 1 WHERE parent_id = old.parent_id AND ordering BETWEEN old.ordering and new.ordering;
29+
UPDATE dbinfo SET value=0 WHERE key='ignore_items_update_triggers';
30+
END;
31+
CREATE TRIGGER update_items_order_backwards BEFORE UPDATE OF ordering ON items WHEN new.ordering < old.ordering AND 0 == (SELECT value FROM dbinfo WHERE key='ignore_items_update_triggers')
32+
BEGIN
33+
UPDATE dbinfo SET value=1 WHERE key='ignore_items_update_triggers';
34+
UPDATE items SET ordering = ordering + 1 WHERE parent_id = old.parent_id AND ordering BETWEEN new.ordering and old.ordering;
35+
UPDATE dbinfo SET value=0 WHERE key='ignore_items_update_triggers';
36+
END;
37+
CREATE TRIGGER update_item_parent AFTER UPDATE OF parent_id ON items WHEN 0 == (SELECT value FROM dbinfo WHERE key='ignore_items_update_triggers')
38+
BEGIN
39+
UPDATE dbinfo SET value=1 WHERE key='ignore_items_update_triggers';
40+
UPDATE items SET ordering = (SELECT ifnull(MAX(ordering),0)+1 FROM items WHERE parent_id=new.parent_id AND ROWID!=old.rowid) WHERE ROWID=old.rowid;
41+
UPDATE items SET ordering = ordering - 1 WHERE parent_id = old.parent_id and ordering > old.ordering;
42+
UPDATE dbinfo SET value=0 WHERE key='ignore_items_update_triggers';
43+
END;
44+
CREATE TRIGGER insert_item AFTER INSERT on items WHEN 0 == (SELECT value FROM dbinfo WHERE key='ignore_items_update_triggers')
45+
BEGIN
46+
UPDATE dbinfo SET value=1 WHERE key='ignore_items_update_triggers';
47+
UPDATE items SET ordering = (SELECT ifnull(MAX(ordering),0)+1 FROM items WHERE parent_id=new.parent_id) WHERE ROWID=new.rowid;
48+
UPDATE dbinfo SET value=0 WHERE key='ignore_items_update_triggers';
49+
END;
50+
CREATE TRIGGER app_inserted AFTER INSERT ON items WHEN new.type = 4 OR new.type = 5
51+
BEGIN
52+
INSERT INTO image_cache VALUES (new.rowid,0,0,NULL,NULL);
53+
END;
54+
CREATE TRIGGER app_deleted AFTER DELETE ON items WHEN old.type = 4 OR old.type = 5
55+
BEGIN
56+
DELETE FROM image_cache WHERE item_id=old.rowid;
57+
END;
58+
CREATE TRIGGER item_deleted AFTER DELETE ON items
59+
BEGIN
60+
DELETE FROM apps WHERE rowid=old.rowid;
61+
DELETE FROM groups WHERE item_id=old.rowid;
62+
DELETE FROM downloading_apps WHERE item_id=old.rowid;
63+
UPDATE dbinfo SET value=1 WHERE key='ignore_items_update_triggers';
64+
UPDATE items SET ordering = ordering - 1 WHERE old.parent_id = parent_id AND ordering > old.ordering;
65+
UPDATE dbinfo SET value=0 WHERE key='ignore_items_update_triggers';
66+
END;
67+
"""
68+
69+
def run(cmd):
70+
return subprocess.run(cmd, shell=True, capture_output=True, text=True).stdout.strip()
71+
72+
def build_category_map():
73+
result = {}
74+
for entry in os.scandir("/Applications"):
75+
if entry.name.endswith(".app") and entry.is_dir(follow_symlinks=True):
76+
bid = run(f'mdls -name kMDItemCFBundleIdentifier -raw "{entry.path}"')
77+
if not bid or bid == "(null)":
78+
continue
79+
if bid in CATEGORY_OVERRIDES:
80+
result[bid] = CATEGORY_OVERRIDES[bid]
81+
else:
82+
cat = run(f'mdls -name kMDItemAppStoreCategory -raw "{entry.path}"')
83+
if cat and cat != "(null)":
84+
result[bid] = cat
85+
return result
86+
87+
def main():
88+
mode = sys.argv[1] if len(sys.argv) > 1 else "apply"
89+
darwin_dir = run("getconf DARWIN_USER_DIR")
90+
lp_db = os.path.join(darwin_dir, "com.apple.dock.launchpad", "db", "db")
91+
92+
if not os.path.exists(lp_db):
93+
print("ERROR: DB not found", file=sys.stderr)
94+
sys.exit(1)
95+
96+
conn = sqlite3.connect(lp_db)
97+
conn.execute("PRAGMA busy_timeout = 5000")
98+
cur = conn.cursor()
99+
100+
cat_map = build_category_map()
101+
102+
# Find pages to modify (skip Apple-only)
103+
cur.execute("""
104+
SELECT rowid FROM items
105+
WHERE type=3 AND parent_id=1 AND uuid NOT LIKE 'HOLDING%'
106+
ORDER BY ordering
107+
""")
108+
all_pages = [r[0] for r in cur.fetchall()]
109+
110+
sort_pages = []
111+
for pid in all_pages:
112+
cur.execute("SELECT COUNT(*) FROM apps a JOIN items i ON a.item_id=i.rowid WHERE i.parent_id=? AND a.bundleid NOT LIKE 'com.apple.%'", (pid,))
113+
non_apple = cur.fetchone()[0]
114+
cur.execute("SELECT COUNT(*) FROM items WHERE type=4 AND parent_id=?", (pid,))
115+
total = cur.fetchone()[0]
116+
if total > 0 and non_apple == 0:
117+
continue
118+
sort_pages.append(pid)
119+
120+
# Collect apps
121+
apps = []
122+
for pid in sort_pages:
123+
cur.execute("SELECT a.item_id, a.bundleid, a.title FROM apps a JOIN items i ON a.item_id=i.rowid WHERE i.parent_id=?", (pid,))
124+
apps.extend(cur.fetchall())
125+
126+
# Assign categories, merge small
127+
app_data = [(iid, t, cat_map.get(bid, "Other")) for iid, bid, t in apps]
128+
cat_counts = Counter(c for _, _, c in app_data)
129+
small = {c for c, n in cat_counts.items() if n < MIN_CATEGORY_SIZE and c != "Other"}
130+
app_data = [(iid, t, "Other" if c in small else c) for iid, t, c in app_data]
131+
132+
# Group
133+
groups = defaultdict(list)
134+
for iid, t, c in app_data:
135+
groups[c].append((iid, t))
136+
for c in groups:
137+
groups[c].sort(key=lambda x: (x[1] or "").lower())
138+
139+
# Preview mode — just print and exit
140+
if mode == "preview":
141+
for c in sorted(groups):
142+
print(f"{c}|{len(groups[c])}")
143+
conn.close()
144+
sys.exit(0)
145+
146+
# Apply
147+
if not sort_pages:
148+
print("ERROR: No pages with third-party apps", file=sys.stderr)
149+
conn.close()
150+
sys.exit(1)
151+
152+
target_page = sort_pages[0]
153+
154+
# Drop triggers (executescript auto-commits — safe, no data changes yet)
155+
drop_sql = "\n".join(f"DROP TRIGGER IF EXISTS {t};" for t in [
156+
"update_items_order", "update_items_order_backwards", "update_item_parent",
157+
"insert_item", "app_inserted", "app_deleted", "item_deleted"])
158+
cur.executescript(drop_sql)
159+
160+
# Data mutations in a single transaction
161+
cur.execute("BEGIN IMMEDIATE")
162+
163+
# Delete extra pages (+ manual groups cleanup since triggers are dropped)
164+
for pid in sort_pages[1:]:
165+
cur.execute("DELETE FROM groups WHERE item_id=?", (pid,))
166+
cur.execute("DELETE FROM items WHERE rowid=?", (pid,))
167+
168+
# Next rowid
169+
cur.execute("SELECT MAX(rowid) FROM items")
170+
next_id = cur.fetchone()[0] + 1
171+
172+
# Create folders
173+
for folder_order, cat in enumerate(sorted(groups)):
174+
folder_id = next_id
175+
next_id += 1
176+
inner_page_id = next_id
177+
next_id += 1
178+
179+
cur.execute("INSERT INTO items (rowid,uuid,flags,type,parent_id,ordering) VALUES (?,?,1,2,?,?)",
180+
(folder_id, str(uuid.uuid4()).upper(), target_page, folder_order))
181+
cur.execute("INSERT INTO groups (item_id,category_id,title) VALUES (?,NULL,?)", (folder_id, cat))
182+
183+
cur.execute("INSERT INTO items (rowid,uuid,flags,type,parent_id,ordering) VALUES (?,?,0,3,?,0)",
184+
(inner_page_id, str(uuid.uuid4()).upper(), folder_id))
185+
cur.execute("INSERT INTO groups (item_id,category_id,title) VALUES (?,NULL,'')", (inner_page_id,))
186+
187+
for app_order, (iid, _) in enumerate(groups[cat]):
188+
cur.execute("UPDATE items SET parent_id=?,ordering=? WHERE rowid=?", (inner_page_id, app_order, iid))
189+
190+
cur.execute("COMMIT")
191+
192+
# Restore triggers (executescript auto-commits — safe, data already committed)
193+
cur.executescript(TRIGGERS_SQL)
194+
195+
conn.close()
196+
197+
run("killall Dock")
198+
print(f"OK|{len(groups)}")
199+
200+
if __name__ == "__main__":
201+
main()

customize/menu.sh

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ customize_menu() {
1616
"Code Editor" \
1717
"Claude Code" \
1818
"Dock Layout" \
19+
"Launchpad" \
1920
"---" \
2021
"Wallpaper" \
2122
"Back")
@@ -28,7 +29,8 @@ customize_menu() {
2829
5) source "$MACRIFT_DIR/customize/editor.sh" && editor_menu ;;
2930
6) source "$MACRIFT_DIR/customize/claude_code.sh" && claude_code_menu ;;
3031
7) source "$MACRIFT_DIR/customize/dock_layout.sh" && dock_layout_menu ;;
31-
8) source "$MACRIFT_DIR/customize/wallpaper.sh" && wallpaper_menu ;;
32+
8) source "$MACRIFT_DIR/customize/launchpad.sh" && launchpad_menu ;;
33+
9) source "$MACRIFT_DIR/customize/wallpaper.sh" && wallpaper_menu ;;
3234
0) break ;;
3335
*) ;;
3436
esac

0 commit comments

Comments
 (0)