From 0b96edff47ebaf6f5e956f1f40542c9c646ac2ec Mon Sep 17 00:00:00 2001 From: Misty02600 Date: Mon, 9 Feb 2026 23:16:16 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0idle=E7=AB=AF?= =?UTF-8?q?=E7=82=B9=EF=BC=8C=E5=8F=AF=E4=BB=A5=E8=8E=B7=E5=8F=96=E6=93=8D?= =?UTF-8?q?=E4=BD=9C=E7=94=B5=E8=84=91=E7=9A=84=E6=9C=80=E5=90=8E=E6=97=B6?= =?UTF-8?q?=E9=95=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 31 +++--- config.toml | 2 +- memory-bank/activeContext.md | 4 + .../tasks/TASK013-add-idle-endpoint.md | 104 ++++++++++++++++++ memory-bank/tasks/_index.md | 7 +- src/peekapi/idle.py | 45 ++++++++ src/peekapi/server.py | 19 ++++ tests/unit/test_idle.py | 89 +++++++++++++++ tests/unit/test_server.py | 67 +++++++++++ 9 files changed, 349 insertions(+), 19 deletions(-) create mode 100644 memory-bank/tasks/TASK013-add-idle-endpoint.md create mode 100644 src/peekapi/idle.py create mode 100644 tests/unit/test_idle.py diff --git a/README.md b/README.md index 0253ae8..8d8e85c 100644 --- a/README.md +++ b/README.md @@ -4,11 +4,12 @@ ## **API 说明** -| **端点** | **方法** | **功能** | **参数** | **成功返回** | **失败返回** | -|------------|------------|--------------|-----------|--------------|--------------| -| **`/screen`** | `GET` | 获取屏幕截图 | - `r`(高斯模糊半径)
- `k`(API 密钥) | - `200 OK`,返回 `image/jpeg` 截图 | - `401 Unauthorized`:配置了 `api_key` 且低模糊度密钥错误
- `403 Forbidden`:私密模式
- `500 Internal Server Error`:截图失败 | -| **`/record`** | `GET` | 获取最近录音 | 无 | - `200 OK`,返回 `audio/wav` 录音文件 | - `403 Forbidden`:私密模式
- `500 Internal Server Error`:录音失败 | -| **`/check`** | `GET/POST` | 检查是否运行 | 无 | - `200 OK` | 无 | +| **端点** | **方法** | **功能** | **参数** | **成功返回** | **失败返回** | +| ------------- | ---------- | ---------------- | ------------------------------------------ | -------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | +| **`/screen`** | `GET` | 获取屏幕截图 | - `r`(高斯模糊半径)
- `k`(API 密钥) | - `200 OK`,返回 `image/jpeg` 截图 | - `401 Unauthorized`:配置了 `api_key` 且低模糊度密钥错误
- `403 Forbidden`:私密模式
- `500 Internal Server Error`:截图失败 | +| **`/record`** | `GET` | 获取最近录音 | 无 | - `200 OK`,返回 `audio/wav` 录音文件 | - `403 Forbidden`:私密模式
- `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` | 无 | ## **使用** @@ -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` | diff --git a/config.toml b/config.toml index 3c5ffe8..f3cbd1a 100644 --- a/config.toml +++ b/config.toml @@ -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] diff --git a/memory-bank/activeContext.md b/memory-bank/activeContext.md index abaeb02..00fdba7 100644 --- a/memory-bank/activeContext.md +++ b/memory-bank/activeContext.md @@ -9,6 +9,9 @@ ## 最近变更 +- 2026-02-09: 添加用户空闲时间端点 (`/idle`) + - 使用 Windows GetLastInputInfo API 获取最后操作时间 + - 返回空闲秒数和最后操作时间 (ISO 格式) - 2026-02-03: 修复 exe 打包后系统托盘图标不显示问题 - 根因:`console=False` 模式下 `sys.stderr` 为 `None`,日志写入导致崩溃 - 修复:在 `logging.py` 中添加 stderr 可用性检测 @@ -27,6 +30,7 @@ ### 已完成功能 - ✅ 屏幕截图 API (`/screen`) - ✅ 音频录制 API (`/record`) +- ✅ 用户空闲时间 API (`/idle`) - ✅ 健康检查 API (`/check`) - ✅ 系统托盘管理 - ✅ 公开/私密模式切换 diff --git a/memory-bank/tasks/TASK013-add-idle-endpoint.md b/memory-bank/tasks/TASK013-add-idle-endpoint.md new file mode 100644 index 0000000..832a88e --- /dev/null +++ b/memory-bank/tasks/TASK013-add-idle-endpoint.md @@ -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) +- 任务完成 diff --git a/memory-bank/tasks/_index.md b/memory-bank/tasks/_index.md index 60b2c9b..fe483be 100644 --- a/memory-bank/tasks/_index.md +++ b/memory-bank/tasks/_index.md @@ -24,6 +24,7 @@ ## Completed +- [TASK013] 添加用户空闲时间端点 - ✅ 2026-02-09 完成(/idle 端点获取用户空闲时间) - [TASK012] 系统托盘图标不显示 - ✅ 2026-02-03 完成(修复 stderr 为 None 导致崩溃) - [TASK011] CI 代码检查修复 - ✅ 2026-02-02 完成(ruff、basedpyright、pytest 全部通过) @@ -49,10 +50,10 @@ | ----------- | ------ | | In Progress | 0 | | Pending | 2 | -| Completed | 9 | +| Completed | 10 | | Abandoned | 0 | -| **总计** | **11** | +| **总计** | **12** | --- -*最后更新: 2026-02-03* +*最后更新: 2026-02-09* diff --git a/src/peekapi/idle.py b/src/peekapi/idle.py new file mode 100644 index 0000000..09cf2a0 --- /dev/null +++ b/src/peekapi/idle.py @@ -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 diff --git a/src/peekapi/server.py b/src/peekapi/server.py index 2bfb157..0dcf105 100644 --- a/src/peekapi/server.py +++ b/src/peekapi/server.py @@ -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 @@ -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(): diff --git a/tests/unit/test_idle.py b/tests/unit/test_idle.py new file mode 100644 index 0000000..93c74d5 --- /dev/null +++ b/tests/unit/test_idle.py @@ -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) diff --git a/tests/unit/test_server.py b/tests/unit/test_server.py index c5051fc..2f909f8 100644 --- a/tests/unit/test_server.py +++ b/tests/unit/test_server.py @@ -256,6 +256,73 @@ def test_record_check_public_first(self, app_client): app_client["recorder"].get_audio.assert_not_called() assert response.status_code == 403 + # ============ /idle 端点测试 ============ + + def test_idle_public_mode_returns_json(self, app_client): + """公开模式下 /idle 返回 JSON""" + from datetime import datetime, timedelta, timezone + + mock_idle_seconds = 123.456 + mock_last_time = datetime.now(timezone(timedelta(hours=8))) + + with patch( + "peekapi.server.get_idle_info", + return_value=(mock_idle_seconds, mock_last_time), + ): + response = app_client["client"].get("/idle") + + assert response.status_code == 200 + assert response.headers["content-type"] == "application/json" + + data = response.json() + assert "idle_seconds" in data + assert "last_input_time" in data + assert data["idle_seconds"] == 123.456 + + def test_idle_private_mode_returns_403(self, app_client): + """私密模式下 /idle 返回 403""" + app_client["config"].basic.is_public = False + + response = app_client["client"].get("/idle") + + assert response.status_code == 403 + assert "瑟瑟中" in response.content.decode("utf-8") + + def test_idle_returns_valid_iso_datetime(self, app_client): + """验证返回的时间是有效的 ISO 格式""" + from datetime import datetime, timedelta, timezone + + mock_last_time = datetime( + 2026, 2, 9, 23, 0, 0, tzinfo=timezone(timedelta(hours=8)) + ) + + with patch( + "peekapi.server.get_idle_info", + return_value=(10.5, mock_last_time), + ): + response = app_client["client"].get("/idle") + + data = response.json() + # 验证可以解析回 datetime + parsed_time = datetime.fromisoformat(data["last_input_time"]) + assert parsed_time == mock_last_time + + def test_idle_seconds_rounded_to_3_decimals(self, app_client): + """验证空闲秒数保留 3 位小数""" + from datetime import datetime, timedelta, timezone + + mock_last_time = datetime.now(timezone(timedelta(hours=8))) + + with patch( + "peekapi.server.get_idle_info", + return_value=(123.456789, mock_last_time), + ): + response = app_client["client"].get("/idle") + + data = response.json() + # 验证四舍五入到 3 位小数 + assert data["idle_seconds"] == 123.457 + # ============ /favicon.ico 端点测试 ============ def test_favicon_not_implemented(self, app_client):