跨平台屏幕开关控屏系统,包含 三端:
| 端 | 形态 | 适用场景 |
|---|---|---|
scroff-server |
Spring Boot 服务 + Web 后台 | 集中控屏:多台叫号机/信息屏统一开关、定时、API 调用 |
android/ |
Android App(Kotlin/Compose) | 单台 Android 设备本地定时开关屏 |
windows/ |
WPF 应用(.NET 8 / .NET Framework 4.8) | 单台 Windows 设备本地定时开关屏 |
Scroff = Screen Off —— 通过 ADB / Win32 API / Android 平台 API 控制屏幕电源。
- 三端覆盖:Android、Windows 都有"本机定时"版,Windows/安卓叫号机还有"集中控屏"版,按规模自由选择。
- 集中控屏 Server:通过 ADB over TCP/IP 同时管理成百上千台叫号机,一台服务器、一个 Web 后台搞定。
- 运行时配置切换:ADB 可执行文件路径不再写死 —— Web
/settings页面可即时切换 Linuxadb/ Windowsadb.exe/ WSL 包装器等多个 profile,重启不丢。 - REST API 对接:外部系统(手机 App、第三方调度)通过 HTTP 直接触发开关屏、查询日志。
- 离线友好构建:Gradle 缓存、阿里云/腾讯云镜像、PowerShell 构建脚本全部就绪,国内网络直连顺畅。
- 踩坑沉淀:实体时间戳列
ddl-auto: update缺 DEFAULT、Spring self-injection、flex 滚动条、cmdif ()复合体 bug 等都已在源码中修过并加测试覆盖。
| 项 | 值 |
|---|---|
| 框架 | Spring Boot 3.3.5 |
| 语言 | Java 21 |
| 视图 | Thymeleaf(服务端渲染) |
| 持久化 | Spring Data JPA + MariaDB(ddl-auto: update) |
| 调度 | Spring @Scheduled + TaskScheduler(Cron 表达式) |
| 控屏通道 | ADB over TCP/IP(子进程短连接,专用线程池) |
| 构建 | Gradle 8.x Kotlin DSL(阿里云 + 腾讯云镜像) |
| 部署 | systemd unit + 一键 bash 脚本 |
| 项 | 值 |
|---|---|
| 语言 | Kotlin |
| UI | Jetpack Compose + Material 3 |
| 持久化 | Room + DataStore Preferences |
| 调度 | AlarmManager + WorkManager + SCHEDULE_EXACT_ALARM |
| 控屏通道 | AccessibilityService 电源键 / 内核 dispdbg |
| 最低支持 | Android 8.0(API 26)/ targetSdk 35 |
| 构建 | AGP 8.x + Gradle Kotlin DSL(KSP 编译 Room) |
| 项 | 值 |
|---|---|
| 主项目 | .NET 8 + WPF(Win 10/11 64-bit) |
| 兼容项目 | .NET Framework 4.8 + WPF(Win 7/8/10 32-bit) |
| MVVM | CommunityToolkit.Mvvm 8.4 |
| 持久化 | JSON 文件(%APPDATA%\Scroff\schedules.json) |
| 调度 | System.Timers.Timer |
| 控屏通道 | Win32 SendMessage(HWND_BROADCAST, WM_SYSCOMMAND, SC_MONITORPOWER) + 模拟输入唤醒 |
scroff/
├── README.md # 本文件
├── LICENSE # MIT
│
├── android/ # Android 端 (Kotlin + Compose)
│ ├── app/
│ │ ├── src/main/
│ │ │ ├── AndroidManifest.xml
│ │ │ ├── java/com/scroff/
│ │ │ │ ├── MainActivity.kt
│ │ │ │ ├── ScroffApp.kt
│ │ │ │ ├── data/ # Room + Repository
│ │ │ │ ├── service/ # 控屏 + 调度
│ │ │ │ ├── receiver/ # BOOT_COMPLETED + 定时广播
│ │ │ │ ├── ui/ # Compose 屏幕 / 组件
│ │ │ │ └── debug/ # 调试日志
│ │ │ └── res/
│ │ └── build.gradle.kts
│ ├── build.gradle.kts
│ └── settings.gradle.kts
│
├── windows/ # Windows 端 (C# WPF)
│ ├── Scroff/ # .NET 8 主项目(Win 10/11)
│ │ ├── Scroff.csproj
│ │ ├── App.xaml(.cs)
│ │ ├── Views/MainWindow.xaml(.cs)
│ │ ├── ViewModels/MainViewModel.cs
│ │ ├── Services/
│ │ │ ├── ScreenControlService.cs # Win32 屏幕控制
│ │ │ ├── SchedulerService.cs # 调度
│ │ │ ├── StorageService.cs # JSON 持久化
│ │ │ └── AutoStartService.cs # 开机自启
│ │ ├── Controls/ # 自定义控件
│ │ └── Converters/ # XAML Value Converter
│ │
│ └── win7/ # .NET Framework 4.8 兼容项目(Win 7)
│ ├── Scroff.Win7.csproj
│ └── ...(同主项目结构)
│
├── scroff-server/ # 集中控屏服务(Spring Boot)
│ ├── build.gradle.kts # Gradle Kotlin DSL
│ ├── settings.gradle.kts # 阿里云/腾讯云镜像
│ ├── gradle.properties
│ ├── gradlew(.bat)
│ ├── gradle/wrapper/
│ ├── src/
│ │ ├── main/
│ │ │ ├── java/com/scroff/server/
│ │ │ │ ├── ScroffServerApplication.java
│ │ │ │ ├── config/ScroffProperties.java
│ │ │ │ ├── controller/ # Dashboard / Device / Schedule / Log / Settings / ScreenApi / Help
│ │ │ │ ├── entity/ # Device / Schedule / ScreenLog / Settings
│ │ │ │ ├── repository/ # JPA Repository
│ │ │ │ ├── service/ # AdbService / DeviceManager / ScreenPowerService / ConfigService
│ │ │ │ └── scheduler/ScheduleExecutor.java
│ │ │ └── resources/
│ │ │ ├── application.yml # 公共配置(提交到 Git)
│ │ │ ├── application-local.yml.example # 本地 + 部署模板(提交)
│ │ │ ├── application-local.yml # 本地真实配置(gitignore)
│ │ │ ├── schema.sql # 完整 schema 模板
│ │ │ ├── static/css/scroff.css
│ │ │ └── templates/ # Thymeleaf 页面
│ │ │ ├── _layout.html / dashboard.html / devices.html / device-form.html
│ │ │ ├── schedules.html / schedule-form.html
│ │ │ ├── logs.html / settings.html / help.html
│ │ └── test/java/com/scroff/server/controller/ScheduleControllerTest.java
│ ├── scripts/
│ │ ├── run-local.sh # Linux/macOS 本地启动
│ │ └── run-local.bat # Windows 本地启动
│ └── deploy/
│ ├── deploy-scroff.sh # Ubuntu 一键部署
│ ├── scroff-server.service # systemd unit
│ ├── migrate-schedule-target-all.sql # 旧库升级用
│ └── README-ubuntu.md # 部署文档
│
├── build-android.ps1 # Android 一键打包
├── build-server.ps1 # Server 一键打包(Gradle)
├── build-win10.ps1 # Win10/11 一键发布(dotnet)
├── build-win7.ps1 # Win7 一键构建(MSBuild)
│
├── NuGet.config # 优先用本地 NuGet 缓存
│
├── debug-scroff-screen-wakeup-fail.md # 历史 debug 记录
└── debug-scroff-storage-access-denied.md
所有构建脚本都在 项目根目录 执行,无需
cd到子目录。
最常见的用法:用 scroff-server 集中管理多台叫号机。
# 1) 编译:默认 bootJar(跳过 test 节省时间)
.\build-server.ps1
# 产物:dist/scroff-server.jar + application.yml + deploy-ubuntu.sh + scroff-server.service本地启动(连本机 MariaDB):
cd scroff-server
copy src\main\resources\application-local.yml.example src\main\resources\application-local.yml
notepad src\main\resources\application-local.yml # 改 DB 密码 + ADB 路径
.\scripts\run-local.bat启动后访问 http://localhost:8880/,首次进入根据 /help 页面 5 分钟接入第一台叫号机。
部署到 Ubuntu(生产环境):
# 1) 拷产物到服务器
scp dist/* user@server:/tmp/
# 2) 一键部署(装 JDK 21 + ADB、构建、写 systemd、启动)
ssh user@server
sudo bash /tmp/deploy-scroff.sh详细步骤见 部署到 Ubuntu。
# 1) 构建 debug APK
.\build-android.ps1
# 产物:android\dist\app-debug.apk
# 2) 设备开启 USB 调试后
adb install -r android\dist\app-debug.apkApp 启动后给无障碍服务授权,即可添加本地定时任务。
# Win 10/11 64 位(.NET 8 Desktop Runtime 需先安装)
.\build-win10.ps1
# 产物:windows\publish\
# Win 7/8/10 32 位(依赖系统自带 .NET Framework 4.8)
.\build-win7.ps1
# 产物:windows\win7\bin\Release\把对应目录整个拷到目标机器,双击 Scroff.exe 即可。
┌────────────────────────┐
Web UI ────► │ Spring Boot 服务 │ ── adb -s host:port shell ...
(8880) │ · Thymeleaf 渲染 │
│ · @Scheduled 触发 │
│ · MariaDB 存任务 │
│ · system_config 热配置 │
└────────────────────────┘
│
│ ADB over TCP/IP
▼
┌──────────────────────┐
│ 叫号机 1 192.168.x.x │
│ 叫号机 2 192.168.x.x │
│ ... │
└──────────────────────┘
| 功能 | 说明 |
|---|---|
| 设备管理 | 增删改查;自动 adb connect;心跳检测(连续 3 次失败才转 OFFLINE) |
| 定时任务 | 6 段 Cron 表达式(秒 分 时 日 月 周);target_all 支持"所有设备"模式 |
| 手动控制 | Web 表格行内一键开/关;批量"全部开"/"全部关" |
| 触发方式记录 | SCHEDULE / MANUAL / API 三种来源完整审计 |
| 日志查询 | /logs 页面 + 设备过滤 + 分页;screen_log 表自动落库 |
| 运行时配置 | system_config 表存 ADB profile 切换;Web /settings 即时生效、重启不丢 |
| REST API | POST /api/devices/{id}/screen/{on|off} + GET /api/devices/{id}/logs |
| 帮助文档 | /help 页面内嵌 5 分钟上手教程 |
| 表 | 说明 |
|---|---|
device |
叫号机:name / host / adb_port / status / last_seen_at / last_error / location / category / notes / sort_order / enabled |
schedule |
定时任务:device_id / name / action(ON/OFF) / cron / enabled / target_all(0=单台,1=所有设备) / last_run_* |
screen_log |
执行历史:device_id / device_name / action / trigger_type / success / message / executed_at |
system_config |
运行时键值配置:cfg_key / cfg_value / updated_at |
时间戳字段(
created_at/updated_at)一律走@PrePersist/@PreUpdate回调,不依赖 DBDEFAULT CURRENT_TIMESTAMP—— 这样ddl-auto: update给旧表加列时不会因缺 DEFAULT 而 INSERT 失败。
system_config表的key/value是 MySQL 保留字,故意用cfg_key/cfg_value避开,避免 Hibernate 生成 DDL 时静默失败。
三层配置(敏感信息逐层隔离):
| 位置 | 提交到 Git? | 内容 |
|---|---|---|
src/main/resources/application.yml |
✅ | 端口、JPA、Thymeleaf、ADB profiles 占位符、默认 active-profile-id |
src/main/resources/application-local.yml.example |
✅ | 本地开发 + 部署模板(占位符 YOUR_LOCAL_DB_PASSWORD) |
src/main/resources/application-local.yml |
❌ | 本地真实配置(scroff-server/.gitignore 已忽略) |
/opt/scroff-server/application-local.yml |
❌ | 部署时生成,chmod 600 |
ADB profile 解析优先级(AdbService.resolveActiveProfile()):
system_config表scroff.adb.active-profile-id(Web UI 切换后写这里)application.yml里scroff.adb.active-profile-idprofiles中第一个enabled: true的- 兜底:
profiles中第一个(可能 disabled)
典型 ADB profile 配置(写在 application.yml):
scroff:
adb:
profiles:
- id: linux-default
name: "Linux 默认 /usr/bin/adb"
executable: /usr/bin/adb
enabled: true
- id: wsl-ubuntu
name: "WSL Ubuntu adb(通过 wsl.exe 包装)"
executable: C:/Windows/System32/wsl.exe
args: ["adb"] # 包装器场景:args 插在 executable 和 ADB 子命令之间
enabled: false
- id: android-sdk
name: "Android SDK (Windows)"
executable: ${ADB_EXECUTABLE_ANDROID_SDK:C:/Users/your-name/AppData/Local/Android/Sdk/platform-tools/adb.exe}
enabled: false
active-profile-id: linux-default
auto-connect-on-startup: true
heartbeat-interval: 30 # 秒
command-timeout: 10000 # 毫秒
screen-off-command: "cd /sys/kernel/debug/dispdbg && echo disp0 > name && echo blank > command && echo {param} > param && echo 1 > start"# 立即开屏
curl -X POST http://server:8880/api/devices/1/screen/on
# 立即关屏
curl -X POST http://server:8880/api/devices/1/screen/off
# 查最近 20 条执行日志
curl "http://server:8880/api/devices/1/logs?size=20"返回示例:
{
"ok": true,
"message": "开屏成功",
"deviceId": 1,
"action": "ON"
}| 状态码 | 含义 |
|---|---|
| 200 | 成功 |
| 500 | 设备不存在 / 不在线 / ADB 命令失败(ok: false + 失败原因在 message) |
详细文档见 scroff-server/deploy/README-ubuntu.md。这里给出最简流程。
1) 准备产物(开发机):
.\build-server.ps1
# 产物在 dist\:
# scroff-server.jar
# application.yml
# deploy-ubuntu.sh
# scroff-server.service2) 上传:
scp dist/* user@server:/tmp/3) 一键部署(服务器):
sudo bash /tmp/deploy-scroff.sh脚本会做:
- 装
openjdk-21-jdk-headless+android-tools-adb(仅首次) - 克隆/拉取 Git 代码到
/opt/scroff-build/ - Gradle 构建(
./gradlew clean bootJar -x test --no-daemon) - 部署 jar +
application.yml到/opt/scroff-server/ - 首次部署时从
.example复制application-local.yml(保留你的真实密码/地址) - 注册 systemd unit
scroff-server.service - 启动 +
systemctl status验证
4) 编辑敏感配置(首次部署必须):
sudo nano /opt/scroff-server/application-local.yml
# 填入远程 MariaDB 地址、账号、密码、ADB 路径
sudo systemctl restart scroff-server5) 升级:
sudo systemctl stop scroff-server
sudo cp /opt/scroff-server/scroff-server.jar /opt/scroff-server/scroff-server.jar.prev
sudo cp dist/scroff-server.jar /opt/scroff-server/
sudo systemctl start scroff-server
# 或直接重跑:sudo bash /tmp/deploy-scroff.sh (自动备份旧 jar + 不覆盖 application-local.yml)旧库(无
target_all列 / 有FK fk_schedule_device)需先跑scroff-server/deploy/migrate-schedule-target-all.sql:mysql -h<host> -u<user> -p scroff < scroff-server/deploy/migrate-schedule-target-all.sql
- 多个本地定时任务,持久化在 Room
- 每日重复 / 一次性 / 工作日 / 周末 等任意 Cron
- 任务启用/禁用
- 调试日志页(
DebugLogScreen) - 开机自启动恢复定时(
BootReceiver+RECEIVE_BOOT_COMPLETED)
两条路径(在 service/KernelScreenController.kt / service/ScreenControlService.kt 中按设备能力选择):
- 优先:内核
dispdbg(root 设备 / 工业平板)adb shell "cd /sys/kernel/debug/dispdbg && echo disp0 > name && echo blank > command && echo {param} > param && echo 1 > start" # {param} = 1 关屏 / 0 开屏
- 备选:
AccessibilityService电源键(任意设备,需用户授权无障碍)
WAKE_LOCK— 唤醒设备RECEIVE_BOOT_COMPLETED— 开机恢复定时SCHEDULE_EXACT_ALARM/USE_EXACT_ALARM— 精确定时(Android 12+/13+ 需用户授权)POST_NOTIFICATIONS— 通知通道- 无障碍服务(
BIND_ACCESSIBILITY_SERVICE)— 控制屏幕电源键
settings.gradle.kts 已配置阿里云镜像优先(maven.aliyun.com),避免 repo.maven.apache.org TLS 握手失败。如果之前拉过官方源失败:
.\gradlew.bat --refresh-dependencies assembleDebug| 项目 | 框架 | 目标机器 | 命令 |
|---|---|---|---|
windows/Scroff/ |
.NET 8 WPF | Win 10/11 64-bit | .\build-win10.ps1 |
windows/win7/ |
.NET Framework 4.8 WPF | Win 7/8/10 32-bit | .\build-win7.ps1 |
build-win7.ps1 通过微软官方的 vswhere 自动定位 MSBuild,能跨盘符/版本找到 Visual Studio 2019/2022 或 Build Tools。
Win32 API SendMessage(HWND_BROADCAST, WM_SYSCOMMAND, SC_MONITORPOWER, ...):
SC_MONITORPOWER + 2→ 关闭显示器SC_MONITORPOWER + -1→ 打开显示器
已知坑(Win 7 兼容性):
HWND_BROADCAST+SendMessage在 Win 7 上不可靠,需改用PostMessage配合EnumWindows枚举可见顶层窗口(见windows/Scroff/Services/ScreenControlService.cs)。- Win 7 上单纯
MONITOR_ON消息不足以唤醒硬件(LCD/LED 显示器需要 DDC/CI 信号),需叠加模拟输入事件(3 像素鼠标移动 +SCROLL LOCK按键),与 OS 消息间隔 30ms。详见debug-scroff-screen-wakeup-fail.md。 - Win 10/11 的 Modern Standby 改变了省电模型,旧 API 在新系统上偶尔不灵;如遇问题优先验证目标机器电源计划。
StorageService.cs 用 FileMode.Create 直接覆盖 + FileShare.Read + 5 次重试 + lock 互斥,规避了"残留 .tmp 文件 + File.Delete 抛 UnauthorizedAccessException"的历史 bug。详见 debug-scroff-storage-access-denied.md。
| 端 | 关屏 | 开屏 | 备选 |
|---|---|---|---|
scroff-server |
dispdbg 内核接口(Android 设备) |
同上 | fb0/blank |
| Android | AccessibilityService 电源键 |
同上 | dispdbg(需 root) |
| Windows | SC_MONITORPOWER(2) |
SC_MONITORPOWER(-1) + 模拟输入 |
不适用 |
这是从本项目踩过的坑里提炼的"新坑预警",给后续接手者省时间。完整细节见各
entity/service源码注释。
- 用 Gradle Kotlin DSL(不是 Maven);
settings.gradle.kts已配阿里云 + 腾讯云镜像 gradle daemon / wrapper缓存放在项目内.gradle/(设$env:GRADLE_USER_HOME),避免污染全局- Gradle 发行包走腾讯云镜像:
https://mirrors.cloud.tencent.com/gradle/ - 从 Maven 迁到 Gradle 后务必删掉
target/、pom.xml、mvnw,否则build-server.ps1会把它们当旧产物
build-server.ps1/build-win10.ps1的Write-Host必须用英文,含 UTF-8 中文会触发 PowerShell 解析器列号错位 bug(误导性 "string is missing the terminator" 错误)cmd /c "cd /d ... && gradlew.bat ..."是 Windows 下从 PowerShell 调 Gradle 的可靠姿势:绕开 PowerShell 调.bat退出码不准- cmd.exe 的 multi-line
if (...)复合体内,echo 行禁止包含(或)—— 会触发 cmd 解析 bug,整个 if 复合体被破坏,前面所有 echo 会被吞
- 时间戳列一律走
@PrePersist/@PreUpdate回调,不要依赖 DBDEFAULT CURRENT_TIMESTAMP——ddl-auto: update不会给已有列补 DEFAULT - 避开 MySQL 保留字做列名(
key/value/order/status/name/type/text/data/time/date等),用@Column(name="xxx_key")显式加前缀 - 给现有 NOT NULL 列加新字段时,写
@PostLoad兜底 ——ddl-auto: update加列时老数据该列可能为 NULL @Transactional+ 自调用陷阱:同类内this.xxx()绕过代理 → 事务失效。用 self-injection(构造注入@Lazy XxxService self),需要事务的方法都改成self.xxx()。注意:混用 Lombok@RequiredArgsConstructor+@Lazy字段会报循环依赖,必须写显式构造器把@Lazy放在参数上@Modifying查询统一加clearAutomatically = true,否则 JPA 一级缓存导致后续findById拿到旧值- 写状态的 Service 方法(
updateStatus/updateLastSeen等)必须public,否则 Spring AOP 代理拦截不到 - schema.sql 的 FK 约束会让"所有设备"模式 schedule 保存失败 —— 删 FK,应用层显式级联
- Thymeleaf 表格加新列要同时改 4 个地方:
<th>、<td>、空数据colspan、(如有)colgroup/col,改完用grep -c '<th'/grep -c '<td'双重确认 - radio + Boolean + 静态
checked会冲突:放弃th:field的隐式 checked,改用name+th:checked显式判断 - checkbox 不勾选时 form 不提交该字段,Boolean 包装类型会绑为 null,null 兜底应是 FALSE(用户的"不勾选"明确意图 = 禁用),不是 TRUE
@ModelAttributeform DTO 字段必须全部在applyForm中显式赋值,缺一个就丢一个。加字段的顺序:先写applyForm全部setXxx,再写 form HTML,最后加 DTO 字段。已有测试ScheduleControllerTest.java覆盖此场景
- flex item +
overflow-y: auto陷阱:flex 容器里 flex item 默认min-height: auto,禁止收缩到内容尺寸以下。在要滚的那个 div 上加min-height: 0 - Spring Boot 默认对
/static/**有缓存头,CSS 改完可能看不到 —— 硬刷新(Ctrl+Shift+R / Ctrl+F5)
- 所有 controller 写 flash 时,失败必须把
last_error拼到消息里,不要只贴 status 字符串 - 列表/详情页的 status badge 必须
th:title="${d.lastError}"鼠标悬停可见 - 启动期强依赖外部命令的路径配置,UI 必须可改且可重启不丢(
profiles+system_config)
MIT License