Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 16 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,12 @@

## **API 说明**

| **端点** | **方法** | **功能** | **参数** | **成功返回** | **失败返回** |
|------------|------------|--------------|-----------|--------------|--------------|
| **`/screen`** | `GET` | 获取屏幕截图 | - `r`(高斯模糊半径)<br>- `k`(API 密钥) | - `200 OK`,返回 `image/jpeg` 截图 | - `401 Unauthorized`:配置了 `api_key` 且低模糊度密钥错误<br>- `403 Forbidden`:私密模式<br>- `500 Internal Server Error`:截图失败 |
| **`/record`** | `GET` | 获取最近录音 | 无 | - `200 OK`,返回 `audio/wav` 录音文件 | - `403 Forbidden`:私密模式<br>- `500 Internal Server Error`:录音失败 |
| **`/check`** | `GET/POST` | 检查是否运行 | 无 | - `200 OK` | 无 |
| **端点** | **方法** | **功能** | **参数** | **成功返回** | **失败返回** |
| ------------- | ---------- | ---------------- | ------------------------------------------ | -------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| **`/screen`** | `GET` | 获取屏幕截图 | - `r`(高斯模糊半径)<br>- `k`(API 密钥) | - `200 OK`,返回 `image/jpeg` 截图 | - `401 Unauthorized`:配置了 `api_key` 且低模糊度密钥错误<br>- `403 Forbidden`:私密模式<br>- `500 Internal Server Error`:截图失败 |
| **`/record`** | `GET` | 获取最近录音 | 无 | - `200 OK`,返回 `audio/wav` 录音文件 | - `403 Forbidden`:私密模式<br>- `500 Internal Server Error`:录音失败 |
| **`/idle`** | `GET` | 获取用户空闲时间 | 无 | - `200 OK`,返回 JSON:`{"idle_seconds": 123.456, "last_input_time": "2026-02-09T23:00:00+08:00"}` | - `403 Forbidden`:私密模式 |
| **`/check`** | `GET/POST` | 检查是否运行 | 无 | - `200 OK` | 无 |

## **使用**

Expand Down Expand Up @@ -64,14 +65,14 @@ gain = 20 # 音量增益倍数

**说明**

| **参数** | **说明** | **默认值** |
|--------------------|------------------------------------------|---------------|
| **`is_public`** | 程序启动时默认是否为公开模式 | `true` |
| **`api_key`** | 低模糊度下获取截图的密钥,留空则不需要key | `""` |
| **`host`** | 监听 IP | `"0.0.0.0"` |
| **`port`** | 监听端口 | `1920` |
| **`radius_threshold`** | 高斯模糊半径阈值,低于该值时获取截屏需要 `api_key` | `3` |
| **`main_screen_only`** | 多显示器下是否只截取主显示器 | `false` |
| **`duration`** | 录音时间(秒) | `20` |
| **`gain`** | 音量增益倍数 | `20` |
| **参数** | **说明** | **默认值** |
| ---------------------- | -------------------------------------------------- | ----------- |
| **`is_public`** | 程序启动时默认是否为公开模式 | `true` |
| **`api_key`** | 低模糊度下获取截图的密钥,留空则不需要key | `""` |
| **`host`** | 监听 IP | `"0.0.0.0"` |
| **`port`** | 监听端口 | `1920` |
| **`radius_threshold`** | 高斯模糊半径阈值,低于该值时获取截屏需要 `api_key` | `3` |
| **`main_screen_only`** | 多显示器下是否只截取主显示器 | `false` |
| **`duration`** | 录音时间(秒) | `20` |
| **`gain`** | 音量增益倍数 | `20` |

2 changes: 1 addition & 1 deletion config.toml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
[basic]
is_public = true # 程序启动时默认是否为公开模式
api_key = "Imkei" # 低模糊度下获取截图的key
host = "127.0.0.1" # 监听IP
host = "0.0.0.0" # 监听IP
port = 1920 # 监听端口

[screenshot]
Expand Down
4 changes: 4 additions & 0 deletions memory-bank/activeContext.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@

## 最近变更

- 2026-02-09: 添加用户空闲时间端点 (`/idle`)
- 使用 Windows GetLastInputInfo API 获取最后操作时间
- 返回空闲秒数和最后操作时间 (ISO 格式)
- 2026-02-03: 修复 exe 打包后系统托盘图标不显示问题
- 根因:`console=False` 模式下 `sys.stderr` 为 `None`,日志写入导致崩溃
- 修复:在 `logging.py` 中添加 stderr 可用性检测
Expand All @@ -27,6 +30,7 @@
### 已完成功能
- ✅ 屏幕截图 API (`/screen`)
- ✅ 音频录制 API (`/record`)
- ✅ 用户空闲时间 API (`/idle`)
- ✅ 健康检查 API (`/check`)
- ✅ 系统托盘管理
- ✅ 公开/私密模式切换
Expand Down
104 changes: 104 additions & 0 deletions memory-bank/tasks/TASK013-add-idle-endpoint.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
# [TASK013] - 添加用户空闲时间端点

**Status:** Completed
**Added:** 2026-02-09
**Updated:** 2026-02-09

## Original Request

为项目添加端点,获取用户最后操作电脑的时间。

## Thought Process

### 背景分析

PeekAPI 是一个本地 API 服务,目前提供:
- `/screen` - 屏幕截图
- `/record` - 系统音频录制
- `/check` - 健康检查

用户希望添加一个新端点,用于获取用户最后操作电脑的时间(空闲时间检测)。

### 技术方案

在 Windows 上,可以使用 Win32 API `GetLastInputInfo` 来获取用户最后一次输入事件(键盘或鼠标)的时间。使用 Python 的 `ctypes` 模块调用此 API。

**核心代码思路:**
```python
import ctypes
from ctypes import Structure, windll, c_uint, sizeof
from datetime import datetime

class LASTINPUTINFO(Structure):
_fields_ = [
('cbSize', c_uint),
('dwTime', c_uint),
]

def get_idle_time() -> tuple[float, datetime]:
"""
获取用户空闲时间
返回: (空闲秒数, 最后操作时间)
"""
lii = LASTINPUTINFO()
lii.cbSize = sizeof(LASTINPUTINFO)
windll.user32.GetLastInputInfo(ctypes.byref(lii))

# GetTickCount 返回系统启动后的毫秒数
current_tick = windll.kernel32.GetTickCount()
idle_ms = current_tick - lii.dwTime
idle_seconds = idle_ms / 1000.0

last_input_time = datetime.now() - timedelta(seconds=idle_seconds)
return idle_seconds, last_input_time
```

### 端点设计

**端点**: `GET /idle`

**响应格式** (JSON):
```json
{
"idle_seconds": 123.456,
"last_input_time": "2026-02-09T22:50:00+08:00"
}
```

**权限控制**:
- 与其他端点一致,受 `is_public` 模式控制
- 私密模式下拒绝请求

## Implementation Plan

- [x] 1.1 创建 `idle.py` 模块,实现 `get_idle_info()` 函数
- [x] 1.2 在 `server.py` 中添加 `/idle` 端点
- [x] 1.3 添加单元测试
- [x] 1.4 更新 README 文档

## Progress Tracking

**Overall Status:** Completed - 100%

### Subtasks

| ID | Description | Status | Updated | Notes |
| --- | ----------------- | -------- | ---------- | --------------------------- |
| 1.1 | 创建 idle.py 模块 | Complete | 2026-02-09 | 使用 Win32 GetLastInputInfo |
| 1.2 | 添加 /idle 端点 | Complete | 2026-02-09 | 遵循现有端点模式 |
| 1.3 | 添加单元测试 | Complete | 2026-02-09 | 9 个测试全部通过 |
| 1.4 | 更新 README 文档 | Complete | 2026-02-09 | 添加 API 说明 |

## Progress Log

### 2026-02-09
- 创建任务文件
- 完成技术方案设计
- 确定使用 Windows GetLastInputInfo API
- ✅ 创建 `src/peekapi/idle.py` 模块
- ✅ 在 `server.py` 添加 `/idle` 端点
- ✅ 创建 `tests/unit/test_idle.py` 测试文件
- ✅ 在 `test_server.py` 添加 `/idle` 端点测试
- ✅ 更新 README.md 添加 API 说明
- ✅ 所有测试通过(33 passed)
- 任务完成
7 changes: 4 additions & 3 deletions memory-bank/tasks/_index.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@

## Completed

- [TASK013] 添加用户空闲时间端点 - ✅ 2026-02-09 完成(/idle 端点获取用户空闲时间)
- [TASK012] 系统托盘图标不显示 - ✅ 2026-02-03 完成(修复 stderr 为 None 导致崩溃)
- [TASK011] CI 代码检查修复 - ✅ 2026-02-02 完成(ruff、basedpyright、pytest 全部通过)

Expand All @@ -49,10 +50,10 @@
| ----------- | ------ |
| In Progress | 0 |
| Pending | 2 |
| Completed | 9 |
| Completed | 10 |
| Abandoned | 0 |
| **总计** | **11** |
| **总计** | **12** |

---

*最后更新: 2026-02-03*
*最后更新: 2026-02-09*
45 changes: 45 additions & 0 deletions src/peekapi/idle.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
"""用户空闲时间检测模块

使用 Windows GetLastInputInfo API 获取用户最后操作电脑的时间。
"""

import ctypes
from ctypes import Structure, c_uint, sizeof
from datetime import datetime, timedelta, timezone

# 北京时间时区
_BEIJING_TZ = timezone(timedelta(hours=8))


class LASTINPUTINFO(Structure):
"""Windows LASTINPUTINFO 结构体"""

_fields_ = [
("cbSize", c_uint),
("dwTime", c_uint),
]


def get_idle_info() -> tuple[float, datetime]:
"""
获取用户空闲时间信息

Returns:
tuple[float, datetime]: (空闲秒数, 最后操作时间)

Note:
最后操作时间使用北京时间(UTC+8)
"""
lii = LASTINPUTINFO()
lii.cbSize = sizeof(LASTINPUTINFO)
ctypes.windll.user32.GetLastInputInfo(ctypes.byref(lii))

# GetTickCount 返回系统启动后的毫秒数
current_tick = ctypes.windll.kernel32.GetTickCount()
idle_ms = current_tick - lii.dwTime
idle_seconds = idle_ms / 1000.0

# 计算最后操作时间
last_input_time = datetime.now(_BEIJING_TZ) - timedelta(seconds=idle_seconds)

return idle_seconds, last_input_time
19 changes: 19 additions & 0 deletions src/peekapi/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from fastapi.responses import PlainTextResponse, Response

from .config import config
from .idle import get_idle_info
from .logging import logger, setup_logging
from .record import recorder
from .screenshot import screenshot
Expand Down Expand Up @@ -93,6 +94,24 @@ def record_route(request: Request):
return Response(content=audio_bytes, media_type="audio/wav")


@app.get("/idle")
def idle_route(request: Request):
"""获取用户空闲时间"""
client_ip = request.client.host if request.client else "unknown"

if not config.basic.is_public:
logger.info(f"[{client_ip}] 空闲时间请求被拒绝: 私密模式")
raise HTTPException(status_code=403, detail="瑟瑟中")

idle_seconds, last_input_time = get_idle_info()
logger.info(f"[{client_ip}] 空闲时间请求成功 (idle={idle_seconds:.1f}s)")

return {
"idle_seconds": round(idle_seconds, 3),
"last_input_time": last_input_time.isoformat(),
}


@app.get("/check")
@app.post("/check")
def check_route():
Expand Down
89 changes: 89 additions & 0 deletions tests/unit/test_idle.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
"""用户空闲时间模块测试"""

import sys
from datetime import datetime, timedelta, timezone
from unittest.mock import MagicMock, patch

import pytest


class TestGetIdleInfo:
"""get_idle_info 函数测试"""

@pytest.mark.skipif(sys.platform != "win32", reason="仅 Windows 平台支持")
def test_get_idle_info_returns_tuple(self):
"""验证返回类型为 (float, datetime) 元组"""
from peekapi.idle import get_idle_info

result = get_idle_info()

assert isinstance(result, tuple)
assert len(result) == 2
assert isinstance(result[0], float)
assert isinstance(result[1], datetime)

@pytest.mark.skipif(sys.platform != "win32", reason="仅 Windows 平台支持")
def test_get_idle_info_returns_non_negative_seconds(self):
"""验证空闲秒数为非负数"""
from peekapi.idle import get_idle_info

idle_seconds, _ = get_idle_info()

assert idle_seconds >= 0

@pytest.mark.skipif(sys.platform != "win32", reason="仅 Windows 平台支持")
def test_get_idle_info_returns_valid_datetime(self):
"""验证最后操作时间是有效的日期时间"""
from peekapi.idle import get_idle_info

_, last_input_time = get_idle_info()

# 检查是否有时区信息
assert last_input_time.tzinfo is not None
# 检查时间不能超过当前时间太多
now = datetime.now(timezone(timedelta(hours=8)))
assert last_input_time <= now

@pytest.mark.skipif(sys.platform != "win32", reason="仅 Windows 平台支持")
def test_get_idle_info_consistency(self):
"""验证空闲秒数和最后操作时间的一致性"""
from peekapi.idle import get_idle_info

idle_seconds, last_input_time = get_idle_info()

# 计算从最后操作时间到现在的时间差
now = datetime.now(timezone(timedelta(hours=8)))
calculated_idle = (now - last_input_time).total_seconds()

# 允许 1 秒的误差(由于执行时间)
assert abs(calculated_idle - idle_seconds) < 1.0


class TestIdleInfoMocked:
"""使用 mock 的空闲时间测试(跨平台)"""

def test_get_idle_info_with_mocked_windows_api(self):
"""测试使用 mock 的 Windows API"""
mock_windll = MagicMock()

# 模拟 GetTickCount 返回 10000ms (10秒)
mock_windll.kernel32.GetTickCount.return_value = 10000

# LASTINPUTINFO.dwTime 模拟返回 5000ms (5秒前的最后输入)
# 这意味着空闲时间为 10000 - 5000 = 5000ms = 5秒
def mock_get_last_input_info(lii_ref):
lii_ref._obj.dwTime = 5000
return True

mock_windll.user32.GetLastInputInfo.side_effect = mock_get_last_input_info

with patch("peekapi.idle.ctypes.windll", mock_windll):
from peekapi.idle import get_idle_info

idle_seconds, last_input_time = get_idle_info()

# 验证空闲时间约为 5 秒
assert abs(idle_seconds - 5.0) < 0.1

# 验证最后输入时间
assert isinstance(last_input_time, datetime)
Loading