我对系统级通知早就没什么耐心了。要么在我专注时打断我,要么在我没看屏幕时滚走。对真正重要的提醒,我总希望能有一个「温和但不会错过」的中间态。
这次写的插件叫 Future Self,意思简单:在 Flow Launcher 里给未来的自己留一句话。它不弹气泡,不响,不进通知中心——只在你下次主动打开 launcher 那一刻,在结果区顶端浮现一行:
⏰ 来自 17 分钟前的你:记得交报告
打开 launcher 的瞬间,大脑刚刚切完任务、最清醒、对外界最敏感。那是植入提醒的黄金时机。
Flow Launcher 插件的本质
Flow Launcher 的官方插件支持 C#/F#、Python、JavaScript/TypeScript。其中 Python 走的是 JSON-RPC:你的脚本被 launcher 启动一个子进程,launcher 把用户输入当 JSON 发到你的 stdin,你把结果列表当 JSON 写回 stdout。
最小的元数据写在 plugin.json 里:
{
"ID": "87a6c094-12e7-4ab1-901f-0281a6ab812e",
"ActionKeyword": "*",
"Name": "Future Self",
"Language": "python",
"ExecuteFileName": "main.py"
}
ID 是插件身份(写死,绝不要改),ActionKeyword 控制 launcher 什么时候调你——这一项是这次最关键的设计决策。
决定一:把 ActionKeyword 设为 "*"
通常你会写 "ActionKeyword": "fs",意味着用户必须先输 fs 才会调你的 query。这样很安全,但有个根本问题:用户已经分心去输别的东西时,你根本看不到。
"*" 是一个特殊值,意为「任何输入都先经过我」。Flow Launcher 的官方文档不太强调这个开关,但它正是某些系统级插件(Everything、剪贴板)能「无前缀工作」的来源。
代价是性能(后面会讲),收益是产品逻辑闭环——任何对 launcher 的查询,都能在结果区插一条「你之前给自己留过这个」。
代码里我用一个软前缀做路由:
def query(self, q):
q = q.strip()
if q == "" or q.lower().startswith("fs"):
# 管理路径:fs done / fs list / fs +30m hello
...
else:
# 浮现路径:仅当存在到期 reminder 时插入一条
due = self._store.due_reminders(now)
if due:
return render_due_only(due, now)
return []
fs 不是 Flow Launcher 认识的关键字——它是我们代码自己识别的「虚拟前缀」。这样一个 plugin 同时承担两个角色:管理面板 + 全局浮现。
决定二:「来自 X 时间前的你」
这层是产品和工程一起用力的薄但关键的一层。技术上它就是 timedelta → str 的映射,工程上甚至不到 30 行代码。但是把「提醒」重塑为「过去自己写的一封信」,在心理上是另一种东西——你不会反感来自更早一点的自己的话,而你会反感系统通知。
def format_delta(now, past):
delta = now - past
secs = int(delta.total_seconds())
if secs < 60:
return "刚刚的你"
if secs < 3600:
return f"{secs // 60} 分钟前的你"
same_day = past.date() == now.date()
if same_day:
return f"{secs // 3600} 小时前的你"
days_ago = (now.date() - past.date()).days
if days_ago == 1:
if past.hour >= 18:
return "昨晚的你"
# ...
故意没用 “X minutes ago” 这种工程师腔的英文,而是中文里「昨晚的你」「周二上午的你」「4 月 25 日的你」。这个判断稍微多了点 if-else,但每个分支都对应一种具体的人类时间感觉。
性能 — 全局插件每次按键都跑你的代码
把 ActionKeyword 设成 "*" 的代价立刻能感受到:用户每按一个字符,launcher 就会调用你的 query 一次。如果你的 query 花 50 ms,体验已经卡了。
我做了三件事保护它。
冷路径优先短路。 绝大多数查询是没有 due reminder 的——直接 return []。
store mtime 缓存。 每次 query 调 store.load() 看似要读盘,但 store 内部记着上次读的 mtime,文件没变就直接返回内存版本:
def load(self):
if not self.path.exists():
...
return
mtime = self.path.stat().st_mtime
if self._loaded_mtime is not None and mtime == self._loaded_mtime:
return # cache hit
...
写操作仅在用户动作时触发。 浮现展示不写盘,只有 done / cancel / snooze 这三个动作才落 JSON。这样高频路径完全是只读。
测下来 query 主路径在没有 due 时,绝大多数耗时来自 Python 进程启动本身,我们的代码只占微秒级。
持久化 — 原子写 + 损坏回滚
存储用一个 JSON 文件:data/reminders.json。Flow Launcher 是单实例,一般不会并发写,但程序崩溃在写到一半的可能性总在。所以保存时:
fd, tmp_path = tempfile.mkstemp(prefix="reminders.json.tmp", dir=str(self.path.parent))
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
os.replace(tmp_path, self.path)
except Exception:
try: os.unlink(tmp_path)
except OSError: pass
raise
写到同目录下的临时文件,再 os.replace 原子替换。os.replace 在同一文件系统上是 POSIX/Windows 都保证原子性的——要么完整生效,要么完整不生效,不会留半个文件。
如果用户(或别的工具)真的把 JSON 弄坏了,load 时不能就这么崩——会把整个插件干掉。所以加了一层 quarantine:
try:
data = json.load(f)
except (json.JSONDecodeError, OSError) as e:
ts = datetime.now().strftime("%Y%m%dT%H%M%S")
target = self.path.with_name(f"reminders.corrupted-{ts}.json")
self.path.rename(target)
self._reminders = {}
self.last_load_warning = f"reminders.json was corrupted ({e}); 已备份并重置"
return
坏文件被改名为 reminders.corrupted-<时间戳>.json 留档,新建空 store,在结果区顶端给一条警告条目。用户看见警告,数据没丢(还在备份里),功能继续可用。
主入口就是要「不出事」
main.py 是唯一直接 import flowlauncher 的文件。它故意写得很薄:建 store、转发 query / context_menu、跑用户动作、外面包一层 try/except 兜底。
def query(self, q=""):
try:
now = datetime.now()
self._store.load()
...
except Exception as e:
return [{"Title": "Future Self 出错了", "SubTitle": str(e)[:200], ...}]
异常抛出去会让整个插件挂掉,launcher 会弹一个生硬的错误。改成在结果区显示一条柔和的「出错了」更不打断手感。
所有真正的逻辑——时间解析、命令路由、状态机、渲染——都在 plugin/ 下的纯 Python 模块,不依赖 SDK。这样可以在 Linux 上 pytest 跑全套(80+ 个用例),不用启 Flow Launcher。这一层 thin adapter / fat core 的分法值这个钱。
装好之后
最后产出 27 KB 的一个 zip,解压到 %APPDATA%\FlowLauncher\Plugins\ 重启 launcher 就能用。命令大致:
fs +30m 记得交报告
fs 21:00 关电脑
fs tomorrow 9:00 跑步
fs done / cancel / snooze 5m
fs history / stats
留过一阵之后再次打开 launcher,顶端会出现一行——这次不是产品在打扰你,而是 17 分钟前的你想起了一件事。
整个项目的核心新意其实就一句:launcher 是认知最清醒的「任务切换时刻」,比系统通知更适合做温柔的提醒。这一句话决定了 ActionKeyword 设 "*",决定了「来自 X 时间前的你」的措辞,也决定了不去写后台进程和系统托盘。技术上它是个小项目,结构上它是一种对通知机制的隐形批评。
代码、设计与实现脚本:github.com/MarchPhantasia/Flow.Launcher.Plugin.FutureSelf。开发过程中和 Claude 协作,完整的 spec 与实施计划在仓库 docs/superpowers/ 下,可对照过程读。