自己用的 macOS 启动面板:把常用的应用、文件夹、链接、命令和文本片段收在一页里,一次点击打开。
它是一个跑在本机的 Web 面板(不是托管站点、也不是浏览器插件)——因为要读 .app 图标、要 open、要 spawn 命令,这些只能由本机进程做;而浏览器只是最省事的渲染层。
没有数据库、没有外部服务:全部配置落在 data/items.json 一个文件里,换机器直接拷贝该文件即可。data/ 整个目录 git-ignore(里面是个人路径和条目)。
宫格(默认档):侧栏是筛选轴(智能 / 类型 / 分组 / 标签),头部三个下拉是视图三轴,卡片右上角是类型徽标,候选应用数写在图标右下角的 +N 角标上。
按类型分组 + 按名称排序:拆轴后才有的组合——以前选了「按类型」就没得选排序。六个类型桶各自按名称排(「置顶」桶也一样),视图仍是宫格。
截图里是演示数据(假条目 + 系统自带 App),不是你机器上的真实内容——真实数据全在
data/items.json,整个data/目录 git-ignore。
npm install
npm run dev # http://127.0.0.1:5178需要 Node ≥ 20.19(Vite 7 的下限),只能在 macOS 跑:打开走 open,.app 图标由 osascript -l JavaScript(NSWorkspace)导出再 sips 缩放,原生路径选择器是 osascript,剪贴板是 pbcopy,bundle id 读 plutil。
端口固定 5178(strictPort),只绑 127.0.0.1,不对外网开放。
npm run typecheck # tsc --noEmit
npm run build # typecheck + vite build → dist/
npm start # build 后用 server/prod.mjs 起静态服务(同样的 /api/*,无 Vite)
PORT=5180 npm start # prod 服务端口可用 PORT 覆盖| 地址 | 作用 |
|---|---|
/ |
启动面板:条目卡片、搜索、侧栏筛选、设置、发现导入 |
/manage.html |
应用管理:管 App 本身(分组、重命名、拖拽排序),不管条目 |
攒条目:N 或顶部「新增」→ 选类型 → 填值(folder / file 有「浏览…」走系统原生选择器,不用手敲路径;名称留空会从值里推断)→ 分组(自由文本,datalist 里是已有分组)、标签(逗号分隔)、备注、「固定到置顶区」。⌘↵ 保存。
批量导入:顶部「发现应用」→ 递归扫 discoverDirs 下所有 .app(默认 /Applications、~/Applications、/System/Applications,可加到 12 个),按名称或路径过滤,勾选后导入进指定分组。同一份 discoverDirs 也喂设置页的应用选择器,所以扫不到的应用也不可能在设置里被选到。
组织:分组和标签是侧栏的筛选轴;「高频」「最近使用」由每次成功打开自动记账(命令只有退出码 0 才算一次)。想改顺序就调排序轴,想换桶就调分组轴。
设置(⌘,)四张卡片:按类型的打开方式、可用终端、应用扫描路径、已失效引用。最后一张列出指向已卸载应用的配置,「清理」只从面板里去掉这些路径,不动磁盘文件。
找东西:顶部搜索框搜当前视图内的条目;⌘K 是跨全部条目的命令面板(fuzzy 匹配,↑↓ 选、Enter 执行、⌘Enter 在 Finder 显示)。面板里的行只负责找:单击=关掉面板、把那条卡片挪进当前视图(挡住它的侧栏筛选/页头搜索会被清掉)并用背景框标一下;双击、右键都没有含义(那份菜单只在卡片上);执行动作走 Enter——和卡片同一套分派:片段→复制、命令→运行、其余→默认打开。
六类条目(卡片徽标上的名字):app 应用 · folder 文件夹 · file 文件 · url 链接 · snippet 片段 · command 命令。
单击就是干活,按条目类型分(这是唯一口径,其他章节不再重复):
| 条目类型 | 单击 | 双击 |
|---|---|---|
app |
立即启动该应用(open 自身,无 250ms 等待) |
无该功能(第二下=再启动一次) |
folder / file / url |
250ms 后用有效清单首个存活应用打开;空=系统默认 | 弹候选应用菜单 |
snippet |
立即复制 | 无该功能(第二下=再复制一次) |
command |
立即后台运行 | 无该功能(第二下被 409「正在运行」挡住) |
「有效清单」=条目自己的候选 → 该类型清单(设置页只有 folder/file/url 三张)→ 都没有才交给 macOS;app 两级都不参与,恒等于「系统默认=启动它自己」。
| 其他手势 | 行为 |
|---|---|
| ⌘ 单击 | 在 Finder 中显示(只对有本地路径的条目;其余会提示「该项没有本地路径」) |
| 右键 | 完整菜单:打开/复制/运行(命令正在跑时这一条是「重新执行」,下面多一行「停止运行」)、在终端中打开、打开方式…(app 无此项)、置顶、编辑、移除 |
| 长按(触摸) | = 右键。500ms 不动即弹同一份菜单,抬手后那次单击会被吞掉,不会顺手把条目启动;滑动超过 12px 视为滚动,取消 |
候选多于一个时,宫格卡片的图标右下角会有 +N 角标,读作另外还有 N 个可选(不含单击就用那个);列表视图不放角标,只在 tooltip 里说。候选里有已卸载的应用时会显示「N 失效」,打开时自动跳过,不会报错。
「移除」只删面板里的条目,永不删磁盘文件。
面板和管理页各有三个下拉,跟 Finder 一个口径:分组方式决定要不要分桶,排序方式决定桶内顺序,视图决定宫格还是列表。
| 轴 | 面板 | 管理页 |
|---|---|---|
| 分组方式 | 无 / 按分组 / 按类型 / 按标签 | 无 / 按分组 / 按所在目录 |
| 排序方式 | 默认顺序 / 名称 / 使用次数 / 最近使用 | 默认顺序 / 名称 |
| 视图 | 宫格 / 列表 | 宫格 / 列表 |
- 默认档 =
分组:按分组 + 排序:默认顺序 + 视图:宫格。 - 面板这档叫**「默认顺序」**而不是「手动顺序」:面板没有拖拽,它就是
items.json里的数组顺序。 - 管理页只有「分组 + 默认顺序」这一档能拖拽(换序、拖进别的组)。拖进派生出来的桶是假动作,所以任一轴挪开就拖不动,并提示「切回『分组 + 默认顺序』才能拖拽」。
- 三条全局规则:搜索框非空 → 强制平铺 + 按相关度排,两个下拉此时不起作用;侧栏已经筛过同一件事时不再重复分节(点进某分组 + 分组=按分组 → 平铺,标签同理);「置顶」桶只在分节时出现(按标签除外)。
- 按标签是唯一多值的分桶:一条带了几个标签就在几个桶里各出现一次(标签本来就是多对多,去重会丢信息);没打标签的收进「无标签」桶、永远排最后;桶内顺序仍跟随排序轴。这一档不单列「置顶」——把置顶条目抽走就等于让它从自己的标签桶里消失;置顶本身不额外做视觉标记,要看它只在侧栏「置顶」里看。
- 排序轴在每个桶内部生效,「置顶」桶也一样(分桶只切池子,不重排)。
- 视图=列表:单列、每行必显值/路径、右侧多一个「最近 MM-DD」。
- 侧栏可收起:页头最左的按钮或 ⌘B,收起后整条不渲染、主区占满。两页各存一档,管理页收起了面板还是展开的。
只有一个断点,两页共用(src/useNarrow.ts):
- 侧栏变抽屉:同一个侧栏节点,窄屏时套上遮罩浮层 + 从左滑入;点任一筛选项后自动关,Esc / 点遮罩也关。
collapsed在窄屏不生效、也不被写:抽屉开合只是临时状态,收起一个浮层不是要记住的偏好。拉回宽屏,原来的收起状态还在。- 页头两行:第一行=侧栏按钮 + 搜索框 + 全局搜索;第二行=分组/排序/视图三轴。窄屏下所有控件同高 36px,三轴保持自然宽度、放不下才换行,不再横向滚动裁切。列表视图的「最近日期」列让位给名称。
- 勾选框常显:本来是 hover 才出现的,触摸没有 hover。
- 右键一律用长按代替(见上表)。
这一轮只动布局:服务照旧绑 127.0.0.1,所以手机现在还打不开面板。真要在手机上用,得先解决监听地址和鉴权(/api/open、/api/run 等于本机执行),那时布局不用再返工。
设置页里 每类一个有序列表(folder / file / url),第一个即默认,拖动改顺序、输入框过滤,不给「默认 + 候选」两套结构。app 不参与这套:应用本身没有「用别的应用打开」这回事,编辑器和右键都不会给它候选入口。
条目自己的 openWith 覆盖类型清单;单击用的就是这个「有效清单」里第一个还在的应用(条目候选 → 该类型清单 → 都没有才交给 macOS),卡片值行前面的应用名就是它。
右键「打开方式…」弹窗、卡片图标右下角的 +N 角标、双击菜单和单击解析看的是同一份有效清单(条目候选 + 该类型清单,去重):条目没自定义时弹窗里直接显示类型清单,动一次手(拖动/增删)就把这份清单固化进该条目的 openWith,此后设置页再改不跟它。「清空」只撤掉条目自定义,改回跟随类型清单。
「在终端中打开 / 用 iTerm 打开 / 用 Warp 打开」来自同一份终端清单(terminals),系统 Terminal 永远锁在首位、不可删——三个应用共用一份,不在两处各配一遍。
这几行只出现在 folder / file 条目上(对 .app 起终端没有意义),command 条目对应的是「在终端里运行」。
命令支持多行。两条执行路径,输出归属不同:
- 后台运行(单击):
$SHELL -i -c <命令>,cwd = ~,stdout/stderr 一起写data/runs/<id>.log(每次运行先截断,日志里永远是你正在看的那一次)。面板里的「运行输出」实时轮询这个文件(尾 64KB),能看到「运行中」和退出码。同一个 id 再次点击会被 409 挡住;要重启走右键的「重新执行」(运行中时它替掉「运行」那一条)或输出弹层里那颗按钮——先停掉面板起的进程再起一次。要停走右键的「停止运行」(运行中才出现,在「重新执行」下面)或输出弹层的「停止」:SIGTERM,3 秒不退再 SIGKILL,卡片标签立刻消失、日志留着。进程还在跑时,卡片名称后面挂着「运行中」标签,退出就自己消失(结果仍然去输出面板看退出码)。这两种都只认面板这一份服务亲手起的进程——服务重启前就在跑的孤儿进程没有句柄,会直接报 409 让你自己kill。 - 在终端里运行(右键):先写
data/runs/<id>.command(chmod 755),再open -a <终端>它。窗口、输出和存活时间都归终端管。
两条都走 zsh -i,因为像 killport 这种是 rc 文件里的函数,不是 PATH 上的二进制;非交互 shell 里它根本不存在。.command 是三家终端唯一的公共入口——Warp 没有 AppleScript 字典。
只有退出码 0 才给条目记一次「使用」。
| 键 | 作用 |
|---|---|
| ⌘K | 全局搜索(命令面板,跨所有条目,↑↓ 选、Enter 执行、⌘Enter 在 Finder 显示;单击=定位到卡片) |
| ⌘, | 打开设置 |
| ⌘B | 收起/展开侧栏(两页各一档) |
/ |
聚焦顶部搜索框 |
| N | 新增条目 |
| ⌘↵ | 编辑器保存 |
| Esc | 关闭弹层(设置 / 右键菜单 / 运行输出 / 打开方式 / 命令面板 / 候选下拉) |
顶层键,全部由面板自己写,可以手改(改坏了服务端会回落默认值而不是崩):
| 键 | 内容 |
|---|---|
items |
条目数组,顺序即「默认顺序」。单项:id, kind, name, value, iconPath, group, tags, pinned, useCount, lastUsedAt, createdAt, note, openWith |
tags |
标签池 |
openByKind |
{ folder, file, url } 三条有序应用列表 |
terminals |
终端清单(系统 Terminal 强制在首位) |
discoverDirs |
扫描目录 |
appLib |
应用管理的分组与手动顺序({ groups: [{ name, apps }] }) |
view |
两页的视图偏好:{ panel: {group,sort,layout,collapsed}, manage: {group,sort,layout,collapsed} }(collapsed = 侧栏收起,默认 false) |
theme |
外观三档:system(默认,跟 macOS 走)/ dark / light;开关在设置弹窗的「外观」卡里,两页共用一档 |
seed |
出厂默认的快照({ kind: { 名称: 上次同步的出厂值 } })。面板不读它,它只用来判断某条种子你到底碰没碰过 |
派生缓存也在 data/ 下:icons/(提取出的 .app 图标)、runs/(命令日志与 .command)。
出厂默认写死在 server/api.mjs 的 COMMON_FOLDERS / COMMON_URLS / COMMON_APPS 三张表里,服务启动时拿它们和 items.json 对账:只有你没碰过的种子条目会跟着代码改(值还等于 seed 记的那次出厂值才动),改过值、改过名、删掉的一律不碰也不复活;有改动就在服务日志里逐条列出来。所以升级后重起一次服务,默认项自己就跟上了,不用你去面板里改。
写配置的口子只有一个 PATCH /api/settings,接受任意子集,按页/按字段合并:只传 view.panel.layout 不会冲掉同页的 group/sort,也不会碰到 manage。未知值读侧回落默认、不抛错(手改坏 items.json 不能把面板弄挂);只有契约违规才 400(容器不是对象、页名不是 panel/manage)。
全部 JSON,只在本机监听。
| 方法 | 路径 | 作用 |
|---|---|---|
| GET | /api/state |
条目 + 标签 + 设置 + appsExist + 分组列表 |
| POST/PATCH/DELETE | /api/items、/api/items/{id} |
增 / 改 / 删(同 kind+value 重复会 409) |
| POST | /api/items/{id}/use |
记一次使用 |
| POST | /api/open {kind,value,app?} |
打开 |
| POST | /api/reveal / /api/copy / /api/terminal |
Finder 显示 / 写剪贴板 / 指定终端打开 |
| POST | /api/run {id, app?, restart?, stop?} |
后台运行;交给终端;重启/停止面板起的进程 |
| GET | /api/runs/{id} |
该命令的日志尾部 + 是否还在跑 |
| GET | /api/discover?q=&limit= / POST /api/import |
扫描 / 批量导入 |
| GET | /api/apps |
扫描到的应用(喂选择器) |
| GET/PATCH | /api/library |
应用管理的整表读写(数组顺序即显示顺序) |
| PATCH | /api/settings |
设置局部更新(见上) |
| POST | /api/pick {kind:'folder'|'file'} |
原生路径选择器 |
| GET | /api/icon?path= / /api/enrich?path= |
图标 / 补图标 |
试一把:
curl -s http://127.0.0.1:5178/api/state | jq '.settings.view'
curl -s -X PATCH http://127.0.0.1:5178/api/settings \
-H 'content-type: application/json' \
-d '{"view":{"panel":{"layout":"list"}}}' | jq '.settings.view'要给 AI 下指令(加各类条目、改设置、跑命令),看 docs/AI-GUIDE.md——字段名、合法值、上限、错误文案、意图→接口对照表都在那一份里;根目录 AGENTS.md 是指针加红线。
Vite 7 + React 19 + TypeScript(strict)+ Tailwind 4。运行时依赖只有 React 两个包,服务端只用 Node 内置模块加系统命令(open / osascript / sips / plutil / pbcopy)。
index.html / manage.html 两个入口
src/
App.tsx 面板:筛选、搜索、三轴视图、右键菜单装配
AppManage.tsx 应用管理页:分组、重命名、拖拽
api.ts 所有 /api/* 的唯一出口(类型化)
types.ts Item / Kind / ViewPrefs 等共享形状
fuzzy.ts 命令面板打分
theme.ts 外观档位:<html data-theme> 的读写,刻意挂在 React 之外
paths.ts useIcon.ts 应用名与默认应用解析 / 图标加载
useNarrow.ts longPress.ts 窄屏断点(768)/ 长按=右键
components/ ItemCard ViewControls Settings Discover NavDrawer …
server/
api.mjs 唯一的 API:路由表在文件末尾,读写 items.json、spawn 命令
icon.jxa JXA 脚本,用 NSWorkspace 导出 .app 真图标
prod.mjs 生产静态服务(同 /api/*,无 Vite)
data/ ← git-ignore:items.json + icons/ + runs/
- 单用户单机。没有任何鉴权,只绑
127.0.0.1——别放到公网或反向代理后面。 - 不替代 Spotlight,也不管窗口。它只承载你自己攒的入口。
- 不碰磁盘。移除条目、清理失效引用都只改
items.json。 - 不同步。
items.json里是个人路径和条目,所以整个data/永不进版本库;备份就是拷文件。
server/api.mjs是 Vite 中间件,和 dev server 同进程——改这个文件会热重启并清空服务端内存缓存(包括扫描结果)。- 生产入口
server/prod.mjs只服务dist/,���命中路径回落到index.html;/manage.html是第二个构建入口(vite.config.mjs的rollupOptions.input),新增页面要在那里登记。 - 外观不能等 JS:
server/api.mjs的withThemeBootstrap在响应时把当前theme内联成一段阻塞脚本(dev 走transformIndexHtml,prod 走prod.mjs),首帧就是对的档位。所以dist/里不含主题、构建也不会把它冻住;但用别的静态服务器直接发dist/会退回 HTML 写死的data-theme="dark",浅色档下会闪一下。 - 前端样式只有
src/index.css一处 token(ink-*/mute-*/accent、card-surface、chip)。新增控件先看src/components/里有没有现成的(Select、AppChips、ViewControls、ContextMenu…),别再攒一份内联样式。
MIT,见 LICENSE。仓库里不含任何个人数据——data/(你的条目、路径、图标与命令日志)整个 git-ignore,克隆下来是一个空配置,起服务时自动写入种子条目。

