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/longBreak → break),再将 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 事件上下文:elapsed、duration、mode 等

注册表 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