Skip to content

Hook System

gxxk-dev edited this page Mar 1, 2026 · 1 revision

钩子系统 (Hook System)

本文档详细介绍 StudyWithMiku 的钩子系统,包括事件驱动架构、触发器与 Provider 模型、引擎匹配逻辑、内置预设、服务端推送以及 Protobuf 消息定义。


架构概述

钩子系统采用 事件驱动 + Provider 模式,监听番茄钟生命周期事件,自动执行通知、提示音、推送和 E-Stim 等操作。核心流程为:focusEventBus 发出事件 → useHooks 接收并转换为触发器 → hookEngine 匹配钩子 → providerRegistry 分发到具体 Provider 执行。

flowchart TB
    subgraph FocusSystem["番茄钟系统"]
        focusEventBus["focusEventBus<br/>transition / tick 事件"]
    end

    subgraph HookSystem["钩子系统"]
        useHooks["useHooks.js<br/>主 Composable(单例)"]
        hookEngine["hookEngine.js<br/>纯函数:映射 / 评估 / 分发"]
        providerRegistry["providerRegistry.js<br/>Provider 注册表(Map 单例)"]
    end

    subgraph Providers["Provider 实现"]
        notification["notification.js<br/>浏览器通知"]
        sound["sound.js<br/>提示音播放"]
        push["push.js<br/>服务端推送"]
        estim["estim.js<br/>E-Stim 控制"]
    end

    focusEventBus -->|"on('transition')<br/>on('tick')"| useHooks
    useHooks -->|"mapTransitionToTrigger()"| hookEngine
    hookEngine -->|"evaluateHooks()"| hookEngine
    hookEngine -->|"dispatchToProviders()"| providerRegistry
    providerRegistry --> notification
    providerRegistry --> sound
    providerRegistry --> push
    providerRegistry --> estim
Loading

文件结构

src/composables/hooks/
├── constants.js              # 枚举常量、触发器分组、存储键
├── hookEngine.js             # 纯函数:事件映射、钩子评估、Provider 分发
├── providerRegistry.js       # Provider 注册表(Map 单例)
├── useHooks.js               # 主 composable(单例模式)
├── presets.js                # 默认钩子与预设模板
└── providers/                # Provider 实现
    ├── notification.js       # 浏览器 Notification API
    ├── sound.js              # 音频播放
    ├── push.js               # Web Push(服务端推送)
    └── estim.js              # E-Stim 设备控制

src/components/settings/tabs/hooks/
├── HookList.vue              # 钩子列表
├── HookEditor.vue            # 钩子编辑器
├── HookPresets.vue           # 预设模板选择
└── HookProviderConfig.vue    # Provider 配置面板

常量定义

HookTrigger 枚举(12 种触发器)

// src/composables/hooks/constants.js

export const HookTrigger = {
  FOCUS_START:     'focus_start',      FOCUS_PAUSE:     'focus_pause',
  FOCUS_RESUME:    'focus_resume',     FOCUS_COMPLETED: 'focus_completed',
  FOCUS_CANCELLED: 'focus_cancelled',  FOCUS_SKIPPED:   'focus_skipped',
  FOCUS_TICK:      'focus_tick',

  BREAK_START:     'break_start',      BREAK_COMPLETED: 'break_completed',
  BREAK_CANCELLED: 'break_cancelled',  BREAK_SKIPPED:   'break_skipped',
  BREAK_TICK:      'break_tick'
}

HookProvider 枚举(4 种 Provider)

export const HookProvider = {
  NOTIFICATION: 'notification',  // 浏览器通知
  SOUND:        'sound',         // 提示音
  PUSH:         'push',          // 服务端推送(Web Push)
  ESTIM:        'estim'          // E-Stim 设备
}

TRIGGER_GROUPS(UI 分组)

分组 包含的触发器
专注组 focus_start, focus_pause, focus_resume, focus_completed, focus_cancelled, focus_skipped, focus_tick
休息组 break_start, break_completed, break_cancelled, break_skipped, break_tick

存储键

export const HOOKS_STORAGE_KEYS = {
  HOOKS:          'swm_hooks',             // 钩子配置列表
  ESTIM_UNLOCKED: 'swm_coyote_unlocked'   // E-Stim 功能解锁标志
}

hookEngine.js - 匹配与分发引擎

纯函数模块,不持有任何状态,导出三个函数。

mapTransitionToTrigger(action, mode)

将 focusEventBus 发出的原始事件映射为 HookTrigger 枚举值。根据 mode 决定前缀(focus 还是 shortBreak/longBreakbreak),再将 action 映射为后缀:

action mode = focus mode = shortBreak / longBreak
start focus_start break_start
pause focus_pause (不适用)
resume focus_resume (不适用)
complete focus_completed break_completed
cancel focus_cancelled break_cancelled
skip focus_skipped break_skipped
tick focus_tick break_tick

evaluateHooks(hooks, trigger, context)

过滤出所有匹配当前触发器的钩子,评估条件:

  1. enabled — 钩子必须处于启用状态
  2. trigger 匹配 — 钩子的 trigger 字段与当前触发器相同
  3. tick 间隔检查 — 对于 focus_tick / break_tick 触发器,context.elapsed 必须是 hook.tickInterval 的非零整数倍
export const evaluateHooks = (hooks, trigger, context) => {
  return hooks.filter(hook => {
    if (!hook.enabled) return false
    if (hook.trigger !== trigger) return false

    // tick 间隔检查
    if (trigger === HookTrigger.FOCUS_TICK || trigger === HookTrigger.BREAK_TICK) {
      if (hook.tickInterval && hook.tickInterval > 0) {
        if (context.elapsed === 0) return false
        if (context.elapsed % hook.tickInterval !== 0) return false
      }
    }
    return true
  })
}

dispatchToProviders(matchedHooks, context)

遍历匹配的钩子,从 providerRegistry 获取对应 Provider,检查 isAvailable() 后调用 execute(hook, context)

完整处理流程

flowchart TB
    Event["focusEventBus 事件<br/>{action, mode, elapsed, ...}"]
    Map["mapTransitionToTrigger()"]
    Evaluate["evaluateHooks()"]
    CheckEnabled{"enabled?"}
    CheckTrigger{"trigger 匹配?"}
    CheckTick{"tick 间隔满足?"}
    Dispatch["dispatchToProviders()"]
    Execute["provider.execute(hook, context)"]

    Event --> Map --> Evaluate
    Evaluate --> CheckEnabled
    CheckEnabled -->|"否"| Skip1["跳过"]
    CheckEnabled -->|"是"| CheckTrigger
    CheckTrigger -->|"否"| Skip2["跳过"]
    CheckTrigger -->|"是"| CheckTick
    CheckTick -->|"否"| Skip3["跳过"]
    CheckTick -->|"是"| Dispatch --> Execute
Loading

providerRegistry.js - Provider 注册表

基于 Map 的单例注册表,管理所有 Provider 的生命周期。

Provider 接口

{
  id: string,                     // 唯一标识,与 HookProvider 枚举值匹配
  init?: () => void,              // 注册时调用
  destroy?: () => void,           // 注销时调用
  isAvailable?: () => boolean,    // 是否可用(如权限检查)
  execute: (hook, context) => void // 执行钩子动作(必须实现)
}
参数 类型 说明
hook Object 完整的钩子配置,包含 action 字段
context Object 事件上下文:elapseddurationmode

注册表 API

方法 说明
register(provider) 注册 Provider(必须有 id),调用 provider.init()
unregister(id) 注销 Provider,调用 provider.destroy()
get(id) 获取 Provider 实例
getAll() 获取所有 Provider 实例数组
has(id) 检查是否已注册
clear() 清除所有 Provider(逐个调用 destroy()
flowchart LR
    Register["register()"] --> Init["init()"]
    Init -.->|"等待触发"| Available["isAvailable()?"]
    Available -->|"true"| Execute["execute()"]
    Available -->|"false"| Skip["跳过"]
    Execute -.->|"注销"| Unregister["unregister()"] --> Destroy["destroy()"]
Loading

useHooks.js - 主 Composable

钩子系统的统一入口,采用单例模式。负责 Provider 注册、钩子加载/持久化、事件订阅和 CRUD 操作。

初始化流程

flowchart TB
    Start["useHooks() 首次调用"]
    RegBuiltin["注册 3 个内置 Provider<br/>notification / sound / push"]
    CheckEstim{"swm_coyote_unlocked<br/>=== 'true' ?"}
    RegEstim["注册 estim Provider"]
    SkipEstim["跳过 estim"]
    LoadHooks["从 localStorage 加载钩子<br/>key: swm_hooks"]
    HasData{"有数据?"}
    Seed["种子 DEFAULT_HOOKS(4 个内置钩子)"]
    UseLoaded["使用已加载的钩子"]
    Subscribe["订阅 focusEventBus<br/>on('transition') / on('tick')"]

    Start --> RegBuiltin --> CheckEstim
    CheckEstim -->|"是"| RegEstim --> LoadHooks
    CheckEstim -->|"否"| SkipEstim --> LoadHooks
    LoadHooks --> HasData
    HasData -->|"否"| Seed --> Subscribe
    HasData -->|"是"| UseLoaded --> Subscribe
Loading

事件订阅

useHooks 订阅 focusEventBus 的两种事件,在回调中依次调用 hookEngine 的三个函数完成匹配和分发:

focusEventBus.on('transition', ({ action, mode, ...rest }) => {
  const trigger = mapTransitionToTrigger(action, mode)
  if (!trigger) return
  const matched = evaluateHooks(hooks.value, trigger, { action, mode, ...rest })
  dispatchToProviders(matched, { action, mode, ...rest })
})

focusEventBus.on('tick', ({ mode, elapsed, duration, ...rest }) => {
  const trigger = mapTransitionToTrigger('tick', mode)
  if (!trigger) return
  const matched = evaluateHooks(hooks.value, trigger, { mode, elapsed, duration, ...rest })
  dispatchToProviders(matched, { mode, elapsed, duration, ...rest })
})

公共 API

名称 类型 说明
hooks Ref<Hook[]> (readonly) 当前所有钩子列表
addHook(hook) Method → id 添加自定义钩子,返回生成的 ID
updateHook(id, updates) Method → boolean 更新指定钩子的属性
removeHook(id) Method → boolean 删除钩子(built-in 不可删除)
clearCustomHooks() Method 清除所有自定义钩子,保留 built-in
applyPreset(preset) Method 应用预设模板,创建新钩子
getHooksByProvider(providerId) Method → Hook[] 按 Provider 过滤钩子
loadFromCloud(cloudData) Method 从云端数据加载钩子(覆盖本地)
getHooksData() Method → Object 获取钩子数据用于云同步 { hooks: [...] }
providerRegistry Object 暴露 Provider 注册表实例

云同步集成

每次 CRUD 操作完成后,persistHooks() 会同步执行两步:

  1. 持久化到 localStorage(swm_hooks
  2. 异步调用 uploadData('hook_settings', getHooksData()) 上传到云端

loadFromCloud(cloudData) 用于从云端覆盖本地钩子数据。


内置钩子与预设

DEFAULT_HOOKS(4 个内置钩子)

初次使用时自动创建,builtIn: true,不可删除。

ID 名称 Provider 触发器 Action 参数
__builtin_focus_complete_notif 专注完成通知 notification focus_completed (默认通知)
__builtin_break_complete_notif 休息结束通知 notification break_completed (默认通知)
__builtin_focus_complete_sound 专注完成提示音 sound focus_completed {soundId: 'chime', volume: 0.7}
__builtin_break_complete_sound 休息结束提示音 sound break_completed {soundId: 'ding', volume: 0.7}

ESTIM_PRESETS(4 个 E-Stim 预设)

需要 E-Stim 功能解锁后才可见。

名称 触发器 Action 类型 参数
暂停惩罚 focus_pause pulse channel A, 3s
取消惩罚 focus_cancelled pulse channel A, 5s
完成奖励 focus_completed strength_set both channels, 30
专注渐增 focus_tick (300s interval) strength_increase channel A, +5

GENERAL_PRESETS(2 个通用预设)

名称 触发器 Provider
专注开始通知 focus_start notification
休息开始提示音 break_start sound

Provider 实现

notification.js - 浏览器通知

  • id: 'notification'
  • isAvailable: Notification.permission === 'granted'
  • execute: 调用 new Notification(title, { body, tag }),从 hook.action 读取参数,支持 tag 去重

sound.js - 提示音播放

  • id: 'sound'
  • isAvailable: 始终可用
  • execute: 根据 hook.action.soundId(如 'chime''ding')查找预置音频,以 hook.action.volume 音量播放

push.js - 服务端推送

  • id: 'push'
  • isAvailable: 检查 Service Worker 注册状态和推送订阅
  • execute: 将推送请求发送到 FocusNotifier Durable Object,由服务端通过 Web Push 投递,客户端关闭后也能送达

estim.js - E-Stim 设备控制

  • id: 'estim'
  • isAvailable: ESTIM_UNLOCKED === 'true' 且设备已连接
  • execute: 根据 hook.action.type 执行操作
type 说明 参数
pulse 脉冲输出 channel, duration_ms, patterns
strength_set 设置强度 channel, value
strength_increase 增加强度 channel, value

服务端推送架构

概述

服务端推送允许在客户端关闭(如手机锁屏)后,仍然在番茄钟完成时发送通知。核心组件是 FocusNotifier Durable Object,通过 Cloudflare Workers 的 alarm() API 在指定时间触发推送。

flowchart TB
    subgraph Client["浏览器"]
        ServiceWorker["Service Worker"]
        useHooks["useHooks (push provider)"]
    end

    subgraph CFWorkers["Cloudflare Workers"]
        PushRoutes["Push 路由 /api/push/*"]
        FocusNotifier["FocusNotifier DO"]
        SendPush["sendPushToUser()"]
        D1["D1: push_subscriptions"]
    end

    subgraph PushService["推送服务"]
        WebPush["Web Push (FCM / APNs / Mozilla)"]
    end

    useHooks -->|"subscribe / schedule"| PushRoutes
    PushRoutes -->|"转发到 DO"| FocusNotifier
    FocusNotifier -->|"alarm()"| SendPush
    SendPush --> D1
    SendPush -->|"VAPID"| WebPush
    WebPush --> ServiceWorker
Loading

FocusNotifier Durable Object

文件: workers/focus-notifier.js

维护服务端番茄钟状态机。客户端注册定时推送后,即使关闭,DO 也会在指定时间通过 alarm() 发送推送。

DO 内部路由

路由 说明
/schedule 注册定时推送(设置 alarm)
/pause 暂停计时(取消 alarm,保存剩余时间)
/resume 恢复计时(重新设置 alarm)
/cancel 取消计时(删除 alarm 和状态)
/update-settings 更新推送设置(如 autoStart)
/state 查询当前状态

alarm() 处理:发送完成推送 → 计算下一阶段 → 如果 autoStart 启用则设置新 alarm,否则等待客户端操作。

Push API 路由

文件: workers/routes/push.js

方法 路径 认证 说明
GET /api/push/vapid-key 获取 VAPID 公钥
POST /api/push/subscribe Bearer 存储推送订阅
DELETE /api/push/subscribe Bearer 删除推送订阅
POST /api/push/session/schedule Bearer 注册定时推送
POST /api/push/session/pause Bearer 暂停服务端计时
POST /api/push/session/resume Bearer 恢复服务端计时
POST /api/push/session/cancel Bearer 取消服务端计时
GET /api/push/session/state Bearer 查询服务端计时状态
POST /api/push/session/update-settings Bearer 更新推送设置

push_subscriptions 表

类型 说明
id text PK 唯一标识
userId text FK 关联用户
endpoint text 推送端点 URL
p256dh text P-256 公钥(Base64URL)
auth text 认证密钥(Base64URL)
createdAt integer 创建时间戳
userAgent text 用户代理字符串

索引: idx_push_subs_user_id (userId)、idx_push_subs_endpoint (endpoint, UNIQUE)

Web Push 完整流程

sequenceDiagram
    participant B as 浏览器
    participant SW as Service Worker
    participant S as Workers
    participant DO as FocusNotifier DO
    participant PS as Push Service

    Note over B,S: 1. 订阅推送
    B->>S: GET /api/push/vapid-key
    S-->>B: VAPID 公钥
    B->>SW: pushManager.subscribe({applicationServerKey})
    SW-->>B: PushSubscription {endpoint, keys}
    B->>S: POST /api/push/subscribe

    Note over B,S: 2. 注册定时推送
    B->>S: POST /api/push/session/schedule {duration, mode}
    S->>DO: 转发,设置 alarm(duration)

    Note over B,DO: 3. 客户端可能已关闭

    Note over DO,PS: 4. 推送通知
    DO->>DO: alarm() 触发
    DO->>S: sendPushToUser(userId)
    S->>PS: Web Push (VAPID 签名)
    PS->>SW: 推送消息
    SW->>B: 显示通知

    Note over DO: 5. 如果 autoStart,设置新 alarm
Loading

Protobuf 消息定义

studymiku.proto 中 Hook 相关的消息定义,用于云端同步和服务端通信。

枚举

enum HookTrigger {
  HOOK_TRIGGER_UNSPECIFIED = 0;
  HOOK_FOCUS_START = 1;  HOOK_FOCUS_PAUSE = 2;  HOOK_FOCUS_RESUME = 3;
  HOOK_FOCUS_COMPLETED = 4;  HOOK_FOCUS_CANCELLED = 5;  HOOK_FOCUS_SKIPPED = 6;
  HOOK_FOCUS_TICK = 7;
  HOOK_BREAK_START = 8;  HOOK_BREAK_COMPLETED = 9;  HOOK_BREAK_CANCELLED = 10;
  HOOK_BREAK_SKIPPED = 11;  HOOK_BREAK_TICK = 12;
}

enum HookProvider {
  HOOK_PROVIDER_UNSPECIFIED = 0;
  PROVIDER_NOTIFICATION = 1;  PROVIDER_SOUND = 2;
  PROVIDER_PUSH = 3;  PROVIDER_ESTIM = 4;
}

Action 消息

message NotificationAction {
  string title = 1;  string body = 2;  optional string tag = 3;
}

message SoundAction {
  string sound_id = 1;  float volume = 2;
}

message PushAction {
  string title = 1;  string body = 2;
}

message EstimAction {
  string type = 1;           // pulse / strength_set / strength_increase
  string channel = 2;       // A / B / both
  int32 value = 3;
  repeated string patterns = 4;
  int32 duration_ms = 5;
  optional string audio_file_id = 6;
}

Hook 消息

message HookAction {
  oneof action {
    NotificationAction notification = 1;
    SoundAction sound = 2;
    PushAction push = 3;
    EstimAction estim = 4;
  }
}

message Hook {
  string id = 1;              bool enabled = 2;
  string name = 3;            HookProvider provider = 4;
  HookTrigger trigger = 5;    int32 tick_interval = 6;
  bool built_in = 7;          HookAction action = 8;
}

message HookSettings {
  repeated Hook hooks = 1;
}

JS 对象与 Protobuf 对应关系

JS 字段 Protobuf 字段 说明
hook.id Hook.id string
hook.enabled Hook.enabled bool
hook.name Hook.name string
hook.provider Hook.provider HookProvider enum
hook.trigger Hook.trigger HookTrigger enum
hook.tickInterval Hook.tick_interval int32,0 表示不使用
hook.builtIn Hook.built_in bool
hook.action Hook.action HookAction oneof

UI 组件

设置界面位于 src/components/settings/tabs/hooks/ 目录,由 4 个组件组成:

组件 职责
HookList.vue 钩子列表,支持启用/禁用、删除,built-in 标记为不可删除
HookEditor.vue 钩子编辑器,含名称、触发器(按 TRIGGER_GROUPS 分组)、Provider、Action 参数
HookPresets.vue 预设模板面板,展示 GENERAL_PRESETS 和 ESTIM_PRESETS(后者需解锁)
HookProviderConfig.vue Provider 全局配置,如推送订阅管理、E-Stim 设备连接
flowchart TB
    SettingsPanel["设置面板 (Hooks Tab)"]
    HookList["HookList.vue"]
    HookEditor["HookEditor.vue"]
    HookPresets["HookPresets.vue"]
    HookProviderConfig["HookProviderConfig.vue"]
    useHooks["useHooks()"]

    SettingsPanel --> HookList
    SettingsPanel --> HookPresets
    SettingsPanel --> HookProviderConfig
    HookList -->|"编辑"| HookEditor
    HookList --> useHooks
    HookEditor --> useHooks
    HookPresets -->|"applyPreset"| useHooks
    HookProviderConfig -->|"providerRegistry"| useHooks
Loading

Clone this wiki locally