18 Commits
Author SHA1 Message Date
mortyzip 5c32b71f7c Remove the download stats workflow 2026-10-10 02:43:49 +00:00
mortyzip 344437f5e8 Add a daily download stats log 2026-10-10 02:40:34 +00:00
mortyzip 74562419eb Move the Nayuki notice to THIRD_PARTY_NOTICES.md so GitHub detects MIT 2026-10-09 20:27:25 +00:00
mortyzip 3900c68331 Show the phone screenshots in light and dark side by side 2026-10-09 20:21:33 +00:00
mortyzip 73861f9506 Label the desktop and phone screenshots 2026-10-09 20:06:03 +00:00
mortyzip 90bfccadd8 Remove the how-it-works line from the README 2026-10-09 20:02:54 +00:00
mortyzip 0050c9a590 Add screenshots, bump to v1.5.0 2026-10-09 19:58:25 +00:00
mortyzip 75f90e4476 Make the control page layout adapt to the screen size 2026-10-09 01:12:23 +00:00
mortyzip c0757cc60d Add light, dark and auto appearance setting 2026-10-09 01:09:17 +00:00
mortyzip 71163ea888 Fix PS4 build: libPS4 strstr takes non-const pointers 2026-10-09 01:01:37 +00:00
mortyzip 0f0cdc34b4 Refuse cross-origin POSTs, add LICENSE, fix PS4 errno check and page races 2026-10-09 00:50:52 +00:00
mortyzip c0d09937b6 Add While transferring mode with live network speeds (v1.4.0) 2026-10-07 17:24:03 +01:00
mortyzip 06b4de44fe Open control page on the console at start, add Buy Me a Coffee (v1.3.0) 2026-10-07 16:52:06 +01:00
mortyzip 383550d3d2 Add keep-awake timers with quick presets and custom duration (v1.2.0) 2026-10-07 14:23:09 +01:00
mortyzip 332ccf4859 Add control page with on/off, QR code and HTTP API (v1.1.0) 2026-10-07 13:53:30 +01:00
mortyzip d4ac76be97 CI: pass full path to llvm-config 2026-10-07 13:03:22 +01:00
mortyzip c79cc4a782 Add PS5 and PS4 KeepAwake payloads with CI release workflow 2026-10-07 13:01:44 +01:00
mortyzip cd9f67260d Add README for KeepAwake v1.0.0 2026-10-07 12:38:48 +01:00
17 changed files with 2756 additions and 3 deletions

No files matched your search

+1
View File
@@ -0,0 +1 @@
buy_me_a_coffee: mortyzip
+80
View File
@@ -0,0 +1,80 @@
name: Build
on:
push:
branches: [main]
tags: ['v*']
pull_request:
workflow_dispatch:
env:
PS5_SDK_VERSION: v0.43
PS4_SDK_VERSION: v1.0.0
jobs:
build:
runs-on: ubuntu-24.04
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install tools
run: |
sudo apt-get update
sudo apt-get install -y clang-18 lld-18 llvm-18 xxd
echo "LLVM_CONFIG=/usr/lib/llvm-18/bin/llvm-config" >> "$GITHUB_ENV"
- name: Download SDKs
env:
GH_TOKEN: ${{ github.token }}
run: |
cd "$RUNNER_TEMP"
gh release download "$PS5_SDK_VERSION" -R ps5-payload-dev/sdk -p ps5-payload-sdk.zip
gh release download "$PS4_SDK_VERSION" -R Scene-Collective/ps4-payload-sdk -p ps4-payload-sdk.zip
unzip -q ps5-payload-sdk.zip
unzip -q ps4-payload-sdk.zip -d ps4-payload-sdk
echo "PS5_PAYLOAD_SDK=$RUNNER_TEMP/ps5-payload-sdk" >> "$GITHUB_ENV"
echo "PS4SDK=$RUNNER_TEMP/ps4-payload-sdk" >> "$GITHUB_ENV"
- name: Check tag matches Makefile version
if: startsWith(github.ref, 'refs/tags/')
run: |
version="v$(make -s version)"
if [ "$version" != "$GITHUB_REF_NAME" ]; then
echo "::error::Tag $GITHUB_REF_NAME does not match Makefile VERSION ($version)"
exit 1
fi
- name: Build
run: make clean all
- name: Upload payloads
uses: actions/upload-artifact@v4
with:
name: payloads
path: |
keepawake_ps5_*.elf
keepawake_ps4_*.bin
if-no-files-found: error
release:
needs: build
if: startsWith(github.ref, 'refs/tags/')
runs-on: ubuntu-24.04
permissions:
contents: write
steps:
- name: Download payloads
uses: actions/download-artifact@v4
with:
name: payloads
- name: Create release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ github.ref_name }}
generate_release_notes: true
files: |
keepawake_ps5_*.elf
keepawake_ps4_*.bin
fail_on_unmatched_files: true
+5
View File
@@ -0,0 +1,5 @@
*.o
*.elf
*.bin
.DS_Store
build/
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 mortyzip
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+88
View File
@@ -0,0 +1,88 @@
# Payload Manager reads the version from the filename, e.g. keepawake_ps5_v1.4.0.elf
VERSION := 1.5.0
PS5_PAYLOAD_SDK ?= $(abspath ../ps5-payload-sdk)
PS4SDK ?= $(abspath ../ps4-payload-sdk)
PS5_HOST ?= ps5
PS5_PORT ?= 9021
PS4_HOST ?= ps4
PS4_PORT ?= 9090
# The SDKs need an LLVM toolchain; use Homebrew's when it isn't on PATH.
ifneq ($(wildcard /opt/homebrew/opt/llvm/bin/llvm-config),)
export LLVM_CONFIG ?= /opt/homebrew/opt/llvm/bin/llvm-config
endif
LLVM_BIN := $(shell $(or $(LLVM_CONFIG),llvm-config) --bindir 2>/dev/null)
LLVM_PFX := $(if $(LLVM_BIN),$(LLVM_BIN)/)
PS5_ELF := keepawake_ps5_v$(VERSION).elf
PS4_BIN := keepawake_ps4_v$(VERSION).bin
# The control page is embedded into the payloads as a C array.
PAGE_H := build/index_html.h
DEPS := src/web.h $(PAGE_H)
DEFINES := -DKEEPAWAKE_VERSION=\"v$(VERSION)\" -Ibuild
# PS5: ELF for the ps5-payload-sdk ELF loader.
PS5_CC := $(PS5_PAYLOAD_SDK)/bin/prospero-clang
PS5_CFLAGS := -Wall -Werror -O2 -lSceSystemService -lSceUserService $(DEFINES)
# PS4: raw binary for GoldHEN's BinLoader, linked against libPS4.
LIBPS4 := $(PS4SDK)/libPS4
PS4_CC := $(LLVM_PFX)clang
PS4_LD := $(or $(wildcard $(LLVM_PFX)ld.lld),ld.lld)
PS4_OBJCOPY := $(LLVM_PFX)llvm-objcopy
PS4_CFLAGS := --target=x86_64-unknown-freebsd -I$(LIBPS4)/include $(DEFINES) \
-Os -std=c11 -ffreestanding -fno-builtin -nostdlib -fPIE \
-ffunction-sections -fdata-sections -masm=intel -march=btver2 \
-Wall -Werror
PS4_LDFLAGS := -T $(LIBPS4)/linker.x --gc-sections --build-id=none \
-L$(LIBPS4) -lPS4
all: ps5 ps4
ps5: $(PS5_ELF)
ps4: $(PS4_BIN)
$(PAGE_H): src/index.html
@mkdir -p build
cd src && xxd -i index.html > ../$@
$(PS5_ELF): src/ps5.c $(DEPS)
$(PS5_CC) $(PS5_CFLAGS) -o $@ src/ps5.c
$(PS4_BIN): src/ps4.c $(DEPS)
@mkdir -p build
$(PS4_CC) $(PS4_CFLAGS) -c -o build/ps4.o src/ps4.c
$(PS4_CC) --target=x86_64-unknown-freebsd -c -o build/ps4_crt0.o $(LIBPS4)/crt0.s
$(PS4_LD) -o build/ps4.elf build/ps4_crt0.o build/ps4.o $(PS4_LDFLAGS)
$(PS4_OBJCOPY) -O binary build/ps4.elf $@
# Runs the PS5 code on this computer with the console calls stubbed out, for
# working on the control page: make host && ./build/keepawake_host
# Needs BSD network headers (struct if_data), so macOS or BSD only.
host: build/keepawake_host
build/keepawake_host: src/ps5.c tools/host_stubs.c $(DEPS)
cc -Wall -Werror $(DEFINES) -DKEEPAWAKE_SETTINGS=\"build/keepawake.cfg\" -o $@ src/ps5.c tools/host_stubs.c
version:
@echo $(VERSION)
clean:
rm -rf build keepawake*.elf keepawake*.bin
test-ps5: $(PS5_ELF)
$(PS5_PAYLOAD_SDK)/bin/prospero-deploy -h $(PS5_HOST) -p $(PS5_PORT) $<
test-ps4: $(PS4_BIN)
nc -w 1 $(PS4_HOST) $(PS4_PORT) < $<
quit-ps5:
curl -fsS -X POST http://$(PS5_HOST):9031/quit
quit-ps4:
curl -fsS -X POST http://$(PS4_HOST):9031/quit
.PHONY: all ps5 ps4 host version clean test-ps5 test-ps4 quit-ps5 quit-ps4
+120
View File
@@ -0,0 +1,120 @@
# KeepAwake
A PS5 and PS4 payload that stops the console from going into rest mode while it runs.
**Desktop**
<p>
<img src="docs/screenshot-light.png" alt="Control page on desktop, light theme" width="49%">
<img src="docs/screenshot-dark.png" alt="Control page on desktop, dark theme" width="49%">
</p>
**Phone**
<p>
<img src="docs/screenshot-phone-light.png" alt="Control page on a phone, light theme" width="240">
<img src="docs/screenshot-phone-dark.png" alt="Control page on a phone, dark theme" width="240">
</p>
## Usage
Download the payloads from the [Releases](https://github.com/mortyzip/KeepAwake/releases) page.
| Console | File | Send to |
|---|---|---|
| PS5 | `keepawake_ps5_v<version>.elf` | ELF loader, port 9021 |
| PS4 | `keepawake_ps4_v<version>.bin` | GoldHEN BinLoader, port 9090 |
- **Start:** send the payload. The control page opens in the console's browser, and the toast shows its address for other devices, for example "Keep Awake v<version> enabled / Control it at http://192.168.1.50:9031". To stop the page opening on the console, switch off "Open on the PS5/PS4 at start" on the page. The setting is saved to `/data/keepawake.cfg`.
- **Turn off and on:** use the control page. Turning it off lets the console go into rest mode, but the payload keeps running, so you can turn it back on from the page without sending the payload again.
- **Timer:** pick 30 min, 1 hour, 2 hours or 4 hours on the page, or set your own (up to 7 days). When the time is up, Keep Awake turns itself off and shows a toast. The payload keeps running, so you can turn it back on from the page.
- **While transferring:** keeps the console awake only while the network is busy, such as a download, an FTP transfer or a PKG install, then lets it rest. Rest mode suspends payloads, so this stops homebrew transfers from being cut off. See [While transferring](#while-transferring).
- **Close:** use "Close Keep Awake" on the page (tap twice to confirm), or send the payload again. This ends the payload, so it needs sending again to start.
Only one copy runs at a time. It uses TCP port 9031 to know whether it's already running.
There is no password. Anyone on your network can open the page and use the API. Browsers on other sites can't, since POSTs with a foreign `Origin` header are refused, but don't expose port 9031 outside your LAN.
### Control page
While it's running, open `http://<console-ip>:9031` in a browser. The layout adapts to the screen: one column on a phone, two on a tablet, three on a computer or TV, so it fits without scrolling. The page shows:
- whether Keep Awake is on or off, and for how long,
- which console it's on,
- the time left on the timer, and when it will turn off,
- quick timers (30 min, 1 hour, 2 hours, 4 hours, no limit), a custom hours-and-minutes timer, and "While transferring",
- the current download and upload speed,
- a button to turn it on or off, and a link to close it,
- a QR code for the page. Scan it from the TV or a computer with your phone's camera to control Keep Awake from your phone,
- settings: whether the page opens on the console when the payload starts, and the "While transferring" threshold and quiet period.
- appearance: light, dark, or auto (light from 7 am to 7 pm, dark otherwise). It's remembered per browser, so your phone and the console can differ.
### While transferring
Every 2 seconds Keep Awake reads the console's network byte counters (all interfaces except loopback) and works out the speed over the last 10 seconds. While either direction is at or above the threshold (500 KB/s by default), it keeps the console awake. When the speed drops below it, Keep Awake waits for the quiet period (5 minutes by default) in case another file starts, then lets the console rest. The console shows a toast when a transfer is detected and when it can rest again.
It can't tell a download from other traffic, so anything busy enough on the network keeps the console awake, Remote Play included. If the counters can't be read on a console, the page says so and the option is unavailable.
The page also has a small HTTP API:
| Request | Does |
|---|---|
| `GET /status` | Returns JSON: `version`, `console`, `active`, `uptime` and `since` (seconds since start and since it was last turned on or off), `timer` (timer length in minutes, or `null`), `remaining` (seconds left, or `null`), `mode` (`always`, `timer`, `auto` or `off`), `net` (whether speeds can be read), `rx_rate` and `tx_rate` (bytes per second), `transferring`, `auto_awake`, `quiet_left` (seconds, or `null`), `interval`, `ip`, `port`, `open_on_start`, `auto_threshold_kb`, `auto_quiet_minutes` |
| `POST /on` | Turns Keep Awake on with no time limit (removing any timer), and returns the status |
| `POST /on?minutes=N` | Turns Keep Awake on for N minutes (1–10080), then off. Also changes the timer if it's already on |
| `POST /on?auto=1` | Turns Keep Awake on in "While transferring" mode |
| `POST /off` | Turns Keep Awake off without closing it, and returns the status |
| `POST /quit` | Closes the payload |
| `POST /settings?…` | Changes any of `open_on_start` (0 or 1), `auto_threshold_kb` (10–102400) and `auto_quiet_minutes` (1–120), saves them, and returns the status |
For example: `curl -X POST "http://<console-ip>:9031/on?minutes=90"`, or `make quit-ps5 PS5_HOST=<ps5-ip>`.
## Building
The Makefile expects the SDKs next to this folder (`../ps5-payload-sdk` and `../ps4-payload-sdk`). Set `PS5_PAYLOAD_SDK` or `PS4SDK` to use them from somewhere else.
```sh
make # build both
make ps5 # or just one
make ps4
make test-ps5 PS5_HOST=<ps5-ip> # build and send
make test-ps4 PS4_HOST=<ps4-ip>
```
Both builds need LLVM (`clang`, `ld.lld`, `llvm-objcopy`). The Makefile uses Homebrew's LLVM automatically when it's installed. Otherwise, set `LLVM_CONFIG` to your `llvm-config`.
The version is set by `VERSION` in the [Makefile](Makefile). It goes into the filenames, where Payload Manager reads it, the toasts, and the control page.
### Working on the control page
The page is [src/index.html](src/index.html). The build embeds it into both payloads with `xxd`. To try it without a console, run the PS5 code on your computer with the console calls stubbed out:
```sh
make host && ./build/keepawake_host
```
Then open http://localhost:9031. This needs BSD network headers, so it builds on macOS or BSD, not Linux.
## Releasing
GitHub Actions builds both payloads on every push to `main`. To publish a release:
1. Bump `VERSION` in the Makefile and commit it to `main`.
2. Tag the commit and push the tag: `git tag v1.5.1 && git push origin v1.5.1`.
The workflow builds the payloads and creates the GitHub Release with them attached. It fails if the tag doesn't match `VERSION`.
## Source
- [src/ps5.c](src/ps5.c): built with the [ps5-payload-sdk](https://github.com/ps5-payload-dev/sdk).
- [src/ps4.c](src/ps4.c): built against libPS4 from the [Scene-Collective ps4-payload-sdk](https://github.com/Scene-Collective/ps4-payload-sdk). libPS4 has no `select()`, so this version checks for connections ten times a second.
- [src/web.h](src/web.h): the HTTP server for the control page, shared by both.
- [src/index.html](src/index.html): the control page, including a small QR code encoder (byte mode, error correction level M, up to 106 bytes) based on [Project Nayuki's](https://www.nayuki.io/page/qr-code-generator-library).
## Support
Keep Awake is free. If it's useful to you, you can [buy me a coffee](https://buymeacoffee.com/mortyzip). The control page has a link too, or a QR code when it's open on the console.
## License
[MIT](LICENSE). The QR code encoder is based on code from Project Nayuki, see [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md).
+23
View File
@@ -0,0 +1,23 @@
# Third-party notices
The QR code encoder in src/index.html is based on the QR Code generator
library by Project Nayuki (https://www.nayuki.io/page/qr-code-generator-library),
used under the following terms:
Copyright (c) Project Nayuki. (MIT License)
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
of the Software, and to permit persons to whom the Software is furnished to do
so, subject to the following conditions:
- The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
- The Software is provided "as is", without warranty of any kind, express or
implied, including but not limited to the warranties of merchantability,
fitness for a particular purpose and noninfringement. In no event shall the
authors or copyright holders be liable for any claim, damages or other
liability, whether in an action of contract, tort or otherwise, arising from,
out of or in connection with the Software or the use or other dealings in the
Software.
Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

-3
View File
@@ -1,3 +0,0 @@
date,release,file,downloads,stars
2026-10-10,v1.5.0,keepawake_ps4_v1.5.0.bin,4,2
2026-10-10,v1.5.0,keepawake_ps5_v1.5.0.elf,11,2
1 date release file downloads stars
2 2026-10-10 v1.5.0 keepawake_ps4_v1.5.0.bin 4 2
3 2026-10-10 v1.5.0 keepawake_ps5_v1.5.0.elf 11 2
+979
View File
@@ -0,0 +1,979 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Keep Awake</title>
<script>
/* Set the theme before the first paint so there's no flash. The same logic
runs again below for changes. Auto is light from 7 am to 7 pm. */
(function () {
var pref = "auto";
try { pref = localStorage.getItem("theme") || "auto"; } catch (e) {}
var h = new Date().getHours();
var dark = pref === "dark" || (pref === "auto" && (h < 7 || h >= 19));
document.documentElement.dataset.theme = dark ? "dark" : "light";
document.documentElement.style.colorScheme = dark ? "dark" : "light";
})();
</script>
<style>
:root {
--bg: #f4f5f7;
--card: #ffffff;
--text: #16181d;
--muted: #5d6470;
--line: #e3e6eb;
--accent: #0f9d76;
--accent-ink: #ffffff;
--accent-soft: rgba(15, 157, 118, .14);
--danger: #c93a3a;
--danger-soft: rgba(201, 58, 58, .1);
--off: #9aa1ab;
}
:root[data-theme="dark"] {
--bg: #0e1014;
--card: #171a20;
--text: #eef0f3;
--muted: #9aa1ad;
--line: #262a32;
--accent: #2fd3a0;
--accent-ink: #062a1f;
--accent-soft: rgba(47, 211, 160, .14);
--danger: #ff6b6b;
--danger-soft: rgba(255, 107, 107, .12);
--off: #5f6672;
}
* { box-sizing: border-box; }
html, body { margin: 0; }
body {
min-height: 100vh;
background: var(--bg);
color: var(--text);
font: 16px/1.45 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
display: flex;
justify-content: center;
padding: 40px 16px;
}
main {
width: 100%;
max-width: 420px;
display: grid;
grid-template-columns: minmax(0, 1fr);
align-items: start;
gap: 16px;
}
header {
display: flex;
align-items: center;
justify-content: space-between;
}
h1 { font-size: 20px; font-weight: 650; margin: 0; letter-spacing: -.01em; }
h2 { font-size: 16px; font-weight: 650; margin: 0; }
.chip {
font-size: 13px;
color: var(--muted);
border: 1px solid var(--line);
border-radius: 999px;
padding: 2px 10px;
}
.card {
background: var(--card);
border: 1px solid var(--line);
border-radius: 18px;
padding: 28px 24px 24px;
}
.status { display: flex; align-items: center; gap: 16px; }
.dot {
flex: none;
width: 52px;
height: 52px;
border-radius: 50%;
display: grid;
place-items: center;
background: var(--accent-soft);
}
.dot::after {
content: "";
width: 18px;
height: 18px;
border-radius: 50%;
background: var(--accent);
}
.on .dot { animation: pulse 2.4s ease-in-out infinite; }
.card:not(.on) .dot { background: var(--line); }
.card:not(.on) .dot::after { background: var(--off); }
@keyframes pulse {
0%, 100% { box-shadow: 0 0 0 0 var(--accent-soft); }
50% { box-shadow: 0 0 0 10px transparent; }
}
@media (prefers-reduced-motion: reduce) { .on .dot { animation: none; } }
.state { font-size: 22px; font-weight: 650; margin: 0; }
.detail { color: var(--muted); margin: 2px 0 0; }
dl {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1px;
margin: 24px 0;
background: var(--line);
border: 1px solid var(--line);
border-radius: 12px;
overflow: hidden;
}
dl div { background: var(--card); padding: 12px 14px; }
dl div.wide { grid-column: span 2; }
dt { font-size: 13px; color: var(--muted); }
dd { margin: 2px 0 0; font-weight: 600; font-variant-numeric: tabular-nums; }
.closed dl, .lost dl, .loading dl { opacity: .5; }
button {
width: 100%;
font: inherit;
font-weight: 600;
padding: 13px 16px;
border-radius: 12px;
cursor: pointer;
border: 1px solid var(--line);
color: var(--text);
background: transparent;
}
button:hover:not(:disabled) { background: var(--line); }
button.primary { border-color: var(--accent); background: var(--accent); color: var(--accent-ink); }
button.primary:hover:not(:disabled) { background: var(--accent); filter: brightness(1.06); }
button:disabled { opacity: .6; cursor: default; }
button:focus-visible { outline: 2px solid var(--text); outline-offset: 2px; }
button.link {
width: auto;
display: block;
margin: 14px auto 0;
padding: 6px 10px;
border: 0;
font-size: 14px;
font-weight: 500;
color: var(--muted);
}
button.link:hover:not(:disabled) { background: none; color: var(--danger); }
button.link.confirm { color: var(--danger); font-weight: 600; }
.note { color: var(--muted); font-size: 14px; margin: 14px 2px 0; text-align: center; }
.progress {
display: block;
height: 4px;
margin-top: 8px;
border-radius: 2px;
background: var(--line);
overflow: hidden;
}
.progress span { display: block; height: 100%; width: 0; background: var(--accent); transition: width 1s linear; }
.timer-label { font-size: 13px; color: var(--muted); margin: 0 0 8px; }
.presets { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin-bottom: 16px; }
.presets button { padding: 10px 6px; font-size: 15px; }
.presets button[aria-pressed="true"] { border-color: var(--accent); background: var(--accent-soft); }
.custom { display: flex; gap: 8px; align-items: flex-end; margin: -4px 0 16px; }
.custom label { flex: 1; min-width: 0; font-size: 13px; color: var(--muted); }
.custom input {
display: block;
width: 100%;
margin-top: 4px;
font: inherit;
font-weight: 600;
font-variant-numeric: tabular-nums;
padding: 10px 12px;
border: 1px solid var(--line);
border-radius: 10px;
background: transparent;
color: var(--text);
}
.custom input:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
.custom button { width: auto; padding: 11px 18px; }
.presets .wide { grid-column: 1 / -1; }
.settings { padding: 20px 20px 6px; }
.setting { display: flex; align-items: center; gap: 16px; padding: 14px 0; border-top: 1px solid var(--line); cursor: pointer; }
.settings h2 + .setting { border-top: 0; margin-top: 4px; }
.setting .text { flex: 1; min-width: 0; }
.setting strong { display: block; font-weight: 600; }
.setting small { display: block; color: var(--muted); font-size: 14px; margin-top: 2px; }
.setting select {
flex: none;
font: inherit;
font-weight: 600;
padding: 8px 10px;
border: 1px solid var(--line);
border-radius: 10px;
background: var(--card);
color: var(--text);
cursor: pointer;
}
.setting select:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
.settings-error { color: var(--danger); font-size: 14px; margin: 0 0 14px; }
.card.waiting .dot { animation: none; background: var(--accent-soft); }
.card.waiting .dot::after { background: var(--accent); opacity: .45; }
.switch {
flex: none;
appearance: none;
-webkit-appearance: none;
width: 46px;
height: 28px;
margin: 0;
border-radius: 14px;
background: var(--line);
position: relative;
cursor: pointer;
transition: background .15s;
}
.switch::after {
content: "";
position: absolute;
top: 3px;
left: 3px;
width: 22px;
height: 22px;
border-radius: 50%;
background: #fff;
box-shadow: 0 1px 2px rgba(0, 0, 0, .25);
transition: transform .15s;
}
.switch:checked { background: var(--accent); }
.switch:checked::after { transform: translateX(18px); }
.switch:disabled { opacity: .6; cursor: default; }
.switch:focus-visible { outline: 2px solid var(--text); outline-offset: 2px; }
@media (prefers-reduced-motion: reduce) { .switch, .switch::after { transition: none; } }
.appearance { padding: 20px; }
.appearance p { color: var(--muted); font-size: 14px; margin: 10px 0 0; }
.segments { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin-top: 12px; }
.segments button { padding: 10px 6px; font-size: 15px; }
.segments button[aria-pressed="true"] { border-color: var(--accent); background: var(--accent-soft); }
.coffee { display: flex; align-items: center; gap: 20px; padding: 20px; }
.coffee > div { flex: 1; }
.coffee p { color: var(--muted); font-size: 14px; margin: 4px 0 12px; }
.coffee .qr { width: 112px; height: 112px; }
.coffee-button {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 10px 16px;
border-radius: 12px;
background: #ffdd00; /* Buy Me a Coffee's brand yellow */
color: #1a1a1a;
font-weight: 650;
text-decoration: none;
}
.coffee-button:hover { filter: brightness(.96); }
.coffee-button:focus-visible { outline: 2px solid var(--text); outline-offset: 2px; }
.phone { display: flex; align-items: center; gap: 20px; padding: 20px; }
.phone p { color: var(--muted); font-size: 14px; margin: 4px 0 8px; }
.qr {
flex: none;
width: 132px;
height: 132px;
background: #fff; /* always light, so scanners read it in dark mode too */
border-radius: 10px;
padding: 6px;
}
.qr svg { display: block; width: 100%; height: 100%; }
.url { font-size: 14px; font-weight: 600; word-break: break-all; }
[hidden] { display: none !important; }
/* Wider screens (tablets, computers, the console's own browser) lay the cards
out in columns so the page fits without scrolling. A phone keeps one column. */
@media (min-width: 640px) {
body { padding: 20px 24px; }
main {
max-width: 860px;
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-template-rows: auto auto auto 1fr auto auto;
grid-template-areas:
"head head"
"status settings"
"status appearance"
"status phone"
"coffee coffee"
"note note";
}
header { grid-area: head; }
#card { grid-area: status; }
#settings { grid-area: settings; }
.appearance { grid-area: appearance; }
#phone { grid-area: phone; }
#coffee { grid-area: coffee; }
#note { grid-area: note; }
dl { margin: 18px 0; }
.presets { margin-bottom: 12px; }
}
@media (min-width: 1100px) {
main {
max-width: 1180px;
grid-template-columns: repeat(3, minmax(0, 1fr));
grid-template-rows: auto auto auto 1fr auto;
grid-template-areas:
"head head head"
"status settings phone"
"status settings coffee"
"status appearance ."
"note note note";
}
}
/* Short screens (a laptop, a TV browser): trim the spacing so it still fits. */
@media (min-width: 1100px) and (max-height: 800px) {
body { padding-top: 12px; padding-bottom: 12px; }
main { gap: 12px; }
#card { padding: 22px 24px 18px; }
dl { margin: 14px 0; }
}
/* Big screens such as a TV: scale everything up. */
@supports (zoom: 1) {
@media (min-width: 1700px) { main { zoom: 1.25; } }
}
</style>
</head>
<body>
<main>
<header>
<h1>Keep Awake</h1>
<span class="chip" id="version">&nbsp;</span>
</header>
<section class="card loading" id="card" aria-live="polite">
<div class="status">
<div class="dot" aria-hidden="true"></div>
<div>
<p class="state" id="state">Connecting…</p>
<p class="detail" id="detail">Checking the console</p>
</div>
</div>
<dl>
<div><dt id="since-label">On for</dt><dd id="since">–</dd></div>
<div><dt>Console</dt><dd id="console">–</dd></div>
<div class="wide">
<dt id="timer-dt">Timer</dt><dd id="timer">–</dd>
<span class="progress" id="progress" hidden><span id="bar"></span></span>
</div>
<div class="wide" id="net-row" hidden>
<dt>Network</dt><dd id="net">–</dd>
</div>
</dl>
<div id="timer-controls" hidden>
<p class="timer-label" id="timer-label">Keep awake for</p>
<div class="presets" role="group" aria-labelledby="timer-label">
<button type="button" data-minutes="30">30 min</button>
<button type="button" data-minutes="60">1 hour</button>
<button type="button" data-minutes="120">2 hours</button>
<button type="button" data-minutes="240">4 hours</button>
<button type="button" data-minutes="0">No limit</button>
<button type="button" id="custom-toggle" aria-expanded="false" aria-controls="custom">Custom</button>
<button type="button" class="wide" id="auto-mode">While transferring</button>
</div>
<form class="custom" id="custom" novalidate hidden>
<label>Hours<input type="number" id="custom-h" min="0" max="168" step="1" inputmode="numeric" value="1"></label>
<label>Minutes<input type="number" id="custom-m" min="0" max="59" step="1" inputmode="numeric" value="30"></label>
<button type="submit" class="primary">Start</button>
</form>
</div>
<button type="button" id="toggle" disabled>Turn off</button>
<button type="button" class="link" id="quit" hidden>Close Keep Awake</button>
</section>
<section class="card phone" id="phone" hidden>
<div class="qr" id="qr" role="img" aria-label="QR code for this page"></div>
<div>
<h2>Control from your phone</h2>
<p>Scan with your phone's camera, or open:</p>
<div class="url" id="url"></div>
</div>
</section>
<section class="card settings" id="settings" hidden>
<h2>Settings</h2>
<label class="setting">
<span class="text">
<strong id="open-on-start-title">Open on the console at start</strong>
<small id="open-on-start-note">Shows this page on the TV each time you send the payload</small>
</span>
<input type="checkbox" class="switch" id="open-on-start" role="switch"
aria-labelledby="open-on-start-title" aria-describedby="open-on-start-note">
</label>
<label class="setting" id="threshold-setting">
<span class="text">
<strong>Counts as transferring above</strong>
<small>For "While transferring", in either direction</small>
</span>
<select id="threshold">
<option value="100">100 KB/s</option>
<option value="500">500 KB/s</option>
<option value="1024">1 MB/s</option>
<option value="5120">5 MB/s</option>
</select>
</label>
<label class="setting" id="quiet-setting">
<span class="text">
<strong>Stay awake after a transfer</strong>
<small>Covers pauses between files before the console can rest</small>
</span>
<select id="quiet">
<option value="2">2 minutes</option>
<option value="5">5 minutes</option>
<option value="10">10 minutes</option>
<option value="15">15 minutes</option>
<option value="30">30 minutes</option>
</select>
</label>
<p class="settings-error" id="settings-error" hidden>Couldn't save that setting. Try again.</p>
</section>
<section class="card appearance">
<h2 id="appearance-title">Appearance</h2>
<div class="segments" role="group" aria-labelledby="appearance-title">
<button type="button" data-mode="light">Light</button>
<button type="button" data-mode="dark">Dark</button>
<button type="button" data-mode="auto">Auto</button>
</div>
<p id="appearance-note"></p>
</section>
<section class="card coffee" id="coffee">
<div class="qr" id="coffee-qr" role="img" aria-label="QR code for Buy Me a Coffee" hidden></div>
<div>
<h2>Buy me a coffee</h2>
<p id="coffee-text">Keep Awake is free. If it's useful to you, you can support it with a coffee.</p>
<a class="coffee-button" id="coffee-link" href="https://buymeacoffee.com/mortyzip"
target="_blank" rel="noopener noreferrer"><span aria-hidden="true">☕</span> Buy me a coffee</a>
</div>
</section>
<p class="note" id="note" hidden>To start it again, send the payload again.</p>
</main>
<script>
/* Small QR code encoder: byte mode, error correction level M, versions 1-6
(up to 106 bytes). Based on Project Nayuki's QR Code generator,
Copyright (c) Project Nayuki, MIT License (see THIRD_PARTY_NOTICES.md).
https://www.nayuki.io/page/qr-code-generator-library
qrEncode(text) returns rows of booleans, true = dark. Throws if the text
doesn't fit. */
var qrEncode = (function () {
var ECC = [0, 10, 16, 26, 18, 24, 16]; // EC codewords per block, level M
var BLOCKS = [0, 1, 1, 1, 2, 2, 4]; // number of blocks, level M
function rawModules(v) {
var r = (16 * v + 128) * v + 64;
if (v >= 2) { var n = Math.floor(v / 7) + 2; r -= (25 * n - 10) * n - 55; }
return r;
}
function dataCodewords(v) { return Math.floor(rawModules(v) / 8) - ECC[v] * BLOCKS[v]; }
function gfMul(x, y) {
var z = 0;
for (var i = 7; i >= 0; i--) { z = (z << 1) ^ ((z >>> 7) * 0x11d); z ^= ((y >>> i) & 1) * x; }
return z;
}
function rsDivisor(deg) {
var r = []; for (var i = 0; i < deg - 1; i++) r.push(0); r.push(1);
var root = 1;
for (i = 0; i < deg; i++) {
for (var j = 0; j < r.length; j++) { r[j] = gfMul(r[j], root); if (j + 1 < r.length) r[j] ^= r[j + 1]; }
root = gfMul(root, 2);
}
return r;
}
function rsRemainder(data, div) {
var r = div.map(function () { return 0; });
data.forEach(function (b) {
var f = b ^ r.shift(); r.push(0);
div.forEach(function (c, i) { r[i] ^= gfMul(c, f); });
});
return r;
}
return function (text) {
var bytes = Array.prototype.slice.call(new TextEncoder().encode(text));
for (var v = 1; v <= 6 && bytes.length + 2 > dataCodewords(v); v++);
if (v > 6) throw new Error("text too long for QR");
// Data bit stream: byte mode, length, data, terminator, padding.
var bits = [], cap = dataCodewords(v) * 8;
function put(val, len) { for (var i = len - 1; i >= 0; i--) bits.push((val >>> i) & 1); }
put(4, 4); put(bytes.length, 8); bytes.forEach(function (b) { put(b, 8); });
put(0, Math.min(4, cap - bits.length));
put(0, (8 - bits.length % 8) % 8);
for (var pad = 0xec; bits.length < cap; pad ^= 0xec ^ 0x11) put(pad, 8);
var data = [];
for (var i = 0; i < bits.length; i += 8) {
var b = 0; for (var k = 0; k < 8; k++) b = (b << 1) | bits[i + k]; data.push(b);
}
// Split into blocks, add error correction, interleave.
var nb = BLOCKS[v], ecl = ECC[v], raw = Math.floor(rawModules(v) / 8);
var nShort = nb - raw % nb, shortLen = Math.floor(raw / nb), div = rsDivisor(ecl);
var blocks = [];
for (i = 0, k = 0; i < nb; i++) {
var dat = data.slice(k, k + shortLen - ecl + (i < nShort ? 0 : 1));
k += dat.length;
var ecc = rsRemainder(dat, div);
if (i < nShort) dat.push(0);
blocks.push(dat.concat(ecc));
}
var words = [];
for (i = 0; i < blocks[0].length; i++) {
blocks.forEach(function (blk, j) { if (i != shortLen - ecl || j >= nShort) words.push(blk[i]); });
}
var size = v * 4 + 17, mod = [], fn = [];
for (i = 0; i < size; i++) { mod.push(new Array(size).fill(false)); fn.push(new Array(size).fill(false)); }
function setFn(x, y, d) { mod[y][x] = d; fn[y][x] = true; }
// Function patterns: timing, finders, alignment.
for (i = 0; i < size; i++) { setFn(6, i, i % 2 == 0); setFn(i, 6, i % 2 == 0); }
[[3, 3], [size - 4, 3], [3, size - 4]].forEach(function (c) {
for (var dy = -4; dy <= 4; dy++) for (var dx = -4; dx <= 4; dx++) {
var d = Math.max(Math.abs(dx), Math.abs(dy)), x = c[0] + dx, y = c[1] + dy;
if (x >= 0 && x < size && y >= 0 && y < size) setFn(x, y, d != 2 && d != 4);
}
});
if (v >= 2) {
var pos = [6, size - 7];
pos.forEach(function (ax, ai) { pos.forEach(function (ay, aj) {
if ((ai == 0 && aj == 0) || (ai == 0 && aj == 1) || (ai == 1 && aj == 0)) return;
for (var dy = -2; dy <= 2; dy++) for (var dx = -2; dx <= 2; dx++)
setFn(ax + dx, ay + dy, Math.max(Math.abs(dx), Math.abs(dy)) != 1);
}); });
}
function drawFormat(mask) {
var d = (0 << 3) | mask, r = d; // level M = 0b00
for (var i = 0; i < 10; i++) r = (r << 1) ^ ((r >>> 9) * 0x537);
var f = ((d << 10) | r) ^ 0x5412;
function bit(i) { return ((f >>> i) & 1) != 0; }
for (i = 0; i <= 5; i++) setFn(8, i, bit(i));
setFn(8, 7, bit(6)); setFn(8, 8, bit(7)); setFn(7, 8, bit(8));
for (i = 9; i < 15; i++) setFn(14 - i, 8, bit(i));
for (i = 0; i < 8; i++) setFn(size - 1 - i, 8, bit(i));
for (i = 8; i < 15; i++) setFn(8, size - 15 + i, bit(i));
setFn(8, size - 8, true);
}
drawFormat(0); // reserve the format areas before placing data
// Place data in the zigzag order.
for (var right = size - 1, n = 0; right >= 1; right -= 2) {
if (right == 6) right = 5;
for (var vert = 0; vert < size; vert++) for (var j = 0; j < 2; j++) {
var x = right - j, y = ((right + 1) & 2) == 0 ? size - 1 - vert : vert;
if (!fn[y][x] && n < words.length * 8) { mod[y][x] = ((words[n >>> 3] >>> (7 - (n & 7))) & 1) != 0; n++; }
}
}
var MASKS = [
function (x, y) { return (x + y) % 2 == 0; },
function (x, y) { return y % 2 == 0; },
function (x, y) { return x % 3 == 0; },
function (x, y) { return (x + y) % 3 == 0; },
function (x, y) { return (Math.floor(x / 3) + Math.floor(y / 2)) % 2 == 0; },
function (x, y) { return x * y % 2 + x * y % 3 == 0; },
function (x, y) { return (x * y % 2 + x * y % 3) % 2 == 0; },
function (x, y) { return ((x + y) % 2 + x * y % 3) % 2 == 0; }
];
function applyMask(m) {
for (var y = 0; y < size; y++) for (var x = 0; x < size; x++)
if (!fn[y][x] && MASKS[m](x, y)) mod[y][x] = !mod[y][x];
}
// Penalty score (runs, 2x2 blocks, finder-like patterns, balance), as in
// the QR spec, to pick the most readable mask.
function penalty() {
var score = 0, dark = 0;
function lineScore(get) {
var s = 0;
for (var a = 0; a < size; a++) {
var run = 0, prev = null, seq = [];
for (var b = 0; b < size; b++) {
var c = get(a, b);
if (c === prev) { run++; } else { if (run >= 5) s += run - 2; run = 1; prev = c; }
seq.push(c ? 1 : 0);
}
if (run >= 5) s += run - 2;
var line = seq.join("");
var re = /(?=(10111010000|00001011101))/g;
while (re.exec(line)) { s += 40; re.lastIndex++; }
}
return s;
}
score += lineScore(function (a, b) { return mod[a][b]; });
score += lineScore(function (a, b) { return mod[b][a]; });
for (var y = 0; y < size - 1; y++) for (var x = 0; x < size - 1; x++) {
var c = mod[y][x];
if (c == mod[y][x + 1] && c == mod[y + 1][x] && c == mod[y + 1][x + 1]) score += 3;
}
for (y = 0; y < size; y++) for (x = 0; x < size; x++) if (mod[y][x]) dark++;
var total = size * size;
score += Math.floor(Math.abs(dark * 20 - total * 10) / total) * 10;
return score;
}
var best = 0, bestScore = Infinity;
for (var m = 0; m < 8; m++) {
applyMask(m); drawFormat(m);
var s = penalty();
if (s < bestScore) { best = m; bestScore = s; }
applyMask(m);
}
applyMask(best); drawFormat(best);
return mod;
};
})();
</script>
<script>
(function () {
var $ = function (id) { return document.getElementById(id); };
var card = $("card"), toggle = $("toggle"), quit = $("quit"), custom = $("custom");
var presets = Array.prototype.slice.call(document.querySelectorAll(".presets [data-minutes]"));
var view = "loading", info = null, baseSince = 0, baseAt = 0;
var pollTimer = null, quitTimer = null, busy = false, shownUrl = null, expiryPolled = false;
var settingBusy = false;
// Appearance: light, dark, or auto (light by day, dark at night). The first
// script in <head> applies the saved choice before the page paints.
var DAY_START = 7, NIGHT_START = 19; // hours, in the browser's local time
var themeButtons = Array.prototype.slice.call(document.querySelectorAll(".segments [data-mode]"));
function savedTheme() {
try { return localStorage.getItem("theme") || "auto"; } catch (e) { return "auto"; }
}
function applyTheme() {
var pref = savedTheme(), h = new Date().getHours();
var dark = pref === "dark" || (pref === "auto" && (h < DAY_START || h >= NIGHT_START));
document.documentElement.dataset.theme = dark ? "dark" : "light";
document.documentElement.style.colorScheme = dark ? "dark" : "light";
themeButtons.forEach(function (b) {
b.setAttribute("aria-pressed", b.dataset.mode === pref ? "true" : "false");
});
setText("appearance-note", pref === "auto" ?
"Light from " + DAY_START + " am to " + (NIGHT_START - 12) + " pm, dark at night." : "");
}
themeButtons.forEach(function (b) {
b.addEventListener("click", function () {
try { localStorage.setItem("theme", b.dataset.mode); } catch (e) {}
applyTheme();
});
});
function formatDuration(s) {
var d = Math.floor(s / 86400), h = Math.floor(s / 3600) % 24,
m = Math.floor(s / 60) % 60, sec = s % 60;
if (d) return d + "d " + h + "h";
if (h) return h + "h " + m + "m";
if (m) return m + "m " + sec + "s";
return sec + "s";
}
function formatMinutes(min) {
var h = Math.floor(min / 60), m = min % 60;
var hs = h + (h == 1 ? " hour" : " hours"), ms = m + (m == 1 ? " minute" : " minutes");
return h && m ? hs + " " + ms : h ? hs : ms;
}
function formatRate(bytes) {
return bytes < 1048576 ? Math.round(bytes / 1024) + " KB/s" : (bytes / 1048576).toFixed(1) + " MB/s";
}
function setText(id, text) { $(id).textContent = text; }
function mode() { return view === "on" && info ? info.mode : null; }
function hasTimer() { return mode() === "timer"; }
function elapsed() { return Math.floor((Date.now() - baseAt) / 1000); }
// Seconds left on the timer, counting down locally between polls.
function remaining() { return Math.max(0, info.remaining - elapsed()); }
// In "While transferring", seconds of quiet left before the console can rest.
function quietLeft() { return Math.max(0, info.quiet_left - elapsed()); }
// Make sure a select can show a value set through the API.
function selectValue(select, value, label) {
var v = String(value), found = false;
for (var i = 0; i < select.options.length; i++) found = found || select.options[i].value === v;
if (!found) select.add(new Option(label, v));
select.value = v;
}
function render() {
var waiting = mode() === "auto" && !info.auto_awake;
card.className = "card " + view + (waiting ? " waiting" : "");
var name = info ? info.console : "console";
var onDetail = mode() !== "auto" ? "Keeping your " + name + " awake" :
waiting ? "Waiting for a transfer. Your " + name + " can rest" :
"Keeping your " + name + " awake while transferring";
var texts = {
loading: ["Connecting…", "Checking the console"],
on: ["On", onDetail],
off: ["Off", "Your " + name + " can go into rest mode"],
closed: ["Closed", "Keep Awake has stopped running"],
lost: ["Not running", "Keep Awake isn't responding"]
}[view];
setText("state", texts[0]);
setText("detail", texts[1]);
if (info) {
setText("version", info.version);
setText("console", info.console);
setText("since-label", info.active ? "On for" : "Off for");
}
tick();
var live = view === "on" || view === "off";
$("timer-controls").hidden = !live;
setText("timer-label", view === "off" ? "Turn on for" : "Keep awake for");
var pressed = null;
if (mode() === "always") pressed = 0;
if (mode() === "timer") pressed = info.timer;
var matched = false;
presets.forEach(function (b) {
var on = pressed !== null && +b.dataset.minutes === pressed;
matched = matched || on;
b.setAttribute("aria-pressed", on ? "true" : "false");
b.disabled = busy;
});
var customToggle = $("custom-toggle");
customToggle.setAttribute("aria-pressed", pressed !== null && !matched ? "true" : "false");
customToggle.setAttribute("aria-expanded", custom.hidden ? "false" : "true");
customToggle.disabled = busy;
var autoButton = $("auto-mode");
autoButton.setAttribute("aria-pressed", mode() === "auto" ? "true" : "false");
autoButton.disabled = busy || !(info && info.net);
autoButton.title = info && !info.net ? "Transfer detection isn't available on this console" : "";
toggle.disabled = busy || view === "loading";
toggle.className = view === "off" ? "primary" : "";
toggle.textContent = view === "on" ? "Turn off" : view === "off" ? "Turn on with no time limit" : "Check again";
quit.hidden = !live;
quit.disabled = busy;
quit.className = quitTimer ? "link confirm" : "link";
quit.textContent = quitTimer ? "Tap again to close Keep Awake" : "Close Keep Awake";
$("note").hidden = view !== "closed" && view !== "lost";
renderQr();
$("settings").hidden = !live || !info;
if (info) {
setText("open-on-start-title", "Open on the " + info.console + " at start");
if (!settingBusy) {
$("open-on-start").checked = info.open_on_start;
selectValue($("threshold"), info.auto_threshold_kb, formatRate(info.auto_threshold_kb * 1024));
selectValue($("quiet"), info.auto_quiet_minutes, formatMinutes(info.auto_quiet_minutes));
}
$("threshold-setting").hidden = $("quiet-setting").hidden = !info.net;
}
["open-on-start", "threshold", "quiet"].forEach(function (id) { $(id).disabled = settingBusy; });
$("net-row").hidden = !(live && info && info.net);
if (info && info.net) setText("net", "↓ " + formatRate(info.rx_rate) + " ↑ " + formatRate(info.tx_rate));
}
function tick() {
if ((view === "on" || view === "off") && info) {
setText("since", formatDuration(baseSince + elapsed()));
}
var timed = hasTimer();
$("progress").hidden = !timed;
setText("timer-dt", mode() === "auto" ? "While transferring" : "Timer");
if (mode() === "auto") {
setText("timer", info.transferring ? "Transfer in progress" :
info.auto_awake && info.quiet_left != null ? "Quiet, can rest in " + formatDuration(quietLeft()) :
"Waiting for a transfer");
return;
}
if (!timed) {
setText("timer", view === "on" ? "No time limit" : view === "off" ? "Off" : "–");
return;
}
var left = remaining();
var offAt = new Date(Date.now() + left * 1000)
.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
setText("timer", formatDuration(left) + " left · turns off at " + offAt);
setText("detail", "Keeping your " + info.console + " awake for " + formatMinutes(info.timer));
$("bar").style.width = (100 * left / (info.timer * 60)) + "%";
// When the timer runs out, the console turns itself off; refresh to show it.
if (left === 0 && !expiryPolled) {
expiryPolled = true;
setTimeout(poll, 1500);
}
}
function startTimer(minutes) {
act(minutes ? "/on?minutes=" + minutes : "/on")
.then(function (data) { custom.hidden = true; apply(data); render(); })
.catch(poll);
}
presets.forEach(function (b) {
b.addEventListener("click", function () { startTimer(+b.dataset.minutes); });
});
$("custom-toggle").addEventListener("click", function () {
custom.hidden = !custom.hidden;
render();
if (!custom.hidden) $("custom-h").focus();
});
// The form is novalidate so this handler always runs and owns validation.
custom.addEventListener("submit", function (e) {
e.preventDefault();
var h = $("custom-h"), m = $("custom-m");
var minutes = (parseInt(h.value, 10) || 0) * 60 + (parseInt(m.value, 10) || 0);
m.setCustomValidity(minutes < 1 || minutes > 10080 ? "Choose between 1 minute and 7 days" : "");
if (!custom.reportValidity()) return;
startTimer(minutes);
});
custom.addEventListener("input", function () { $("custom-m").setCustomValidity(""); });
$("auto-mode").addEventListener("click", function () {
act("/on?auto=1")
.then(function (data) { custom.hidden = true; apply(data); render(); })
.catch(poll);
});
function saveSetting(query) {
settingBusy = true;
render();
request("POST", "/settings?" + query)
.then(function (data) {
settingBusy = false;
$("settings-error").hidden = true;
apply(data);
render();
})
.catch(function () {
settingBusy = false;
$("settings-error").hidden = false;
render();
});
}
$("open-on-start").addEventListener("change", function (e) {
saveSetting("open_on_start=" + (e.target.checked ? 1 : 0));
});
$("threshold").addEventListener("change", function (e) {
saveSetting("auto_threshold_kb=" + e.target.value);
});
$("quiet").addEventListener("change", function (e) {
saveSetting("auto_quiet_minutes=" + e.target.value);
});
// The address phones should use: this page's own address, unless it was
// opened on the console itself, where the console's LAN IP is needed.
function phoneUrl() {
var host = location.hostname;
if (host && host !== "localhost" && host !== "127.0.0.1") {
return location.protocol + "//" + location.host + "/";
}
if (info && info.ip) return "http://" + info.ip + ":" + info.port + "/";
return null;
}
function renderQr() {
var url = phoneUrl();
$("phone").hidden = !url || view === "closed" || view === "lost";
if (!url || url === shownUrl) return;
shownUrl = url;
try {
$("qr").innerHTML = qrSvg(url);
} catch (e) {
// Address too long for the encoder: show the link only.
$("qr").innerHTML = "";
}
$("qr").hidden = !$("qr").innerHTML;
setText("url", url.replace(/\/$/, ""));
}
function qrSvg(text) {
var m = qrEncode(text), n = m.length, d = "";
for (var y = 0; y < n; y++) for (var x = 0; x < n; x++)
if (m[y][x]) d += "M" + (x + 4) + " " + (y + 4) + "h1v1h-1z";
return '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ' + (n + 8) + " " + (n + 8) +
'" shape-rendering="crispEdges"><path fill="#000" d="' + d + '"/></svg>';
}
// Paying from the console's browser isn't practical, so on a PlayStation
// show a QR code to open the page on a phone instead of a link.
if (/PlayStation/i.test(navigator.userAgent)) {
var coffeeQr = $("coffee-qr");
coffeeQr.innerHTML = qrSvg($("coffee-link").href);
coffeeQr.hidden = false;
$("coffee-link").hidden = true;
setText("coffee-text", "Keep Awake is free. If it's useful to you, scan this with your phone to support it with a coffee.");
}
function apply(data) {
info = data;
baseSince = data.since;
baseAt = Date.now();
view = data.active ? "on" : "off";
if (data.remaining) expiryPolled = false;
}
function request(method, path) {
return fetch(path, { method: method, cache: "no-store" })
.then(function (r) { if (!r.ok) throw r; return r.json(); });
}
// A failed poll stops polling and shows "Not running"; "Check again" restarts it.
function poll() {
clearTimeout(pollTimer);
request("GET", "/status")
.then(function (data) {
if (view === "closed") return;
if (!busy) { apply(data); render(); }
pollTimer = setTimeout(poll, 3000);
})
.catch(function () {
if (view !== "closed") { view = "lost"; render(); }
});
}
function act(path) {
busy = true;
render();
return request("POST", path).then(
function (data) { busy = false; return data; },
function (e) { busy = false; throw e; });
}
toggle.addEventListener("click", function () {
if (view === "on" || view === "off") {
act(view === "on" ? "/off" : "/on")
.then(function (data) { apply(data); render(); })
.catch(poll);
} else {
view = "loading";
render();
poll();
}
});
quit.addEventListener("click", function () {
if (!quitTimer) {
quitTimer = setTimeout(function () { quitTimer = null; render(); }, 3000);
render();
return;
}
clearTimeout(quitTimer);
quitTimer = null;
clearTimeout(pollTimer);
act("/quit")
.then(function () { view = "closed"; render(); })
.catch(poll);
});
setInterval(tick, 1000);
setInterval(applyTheme, 60 * 1000); // auto switches at 7 am and 7 pm
applyTheme();
poll();
})();
</script>
</body>
</html>
+317
View File
@@ -0,0 +1,317 @@
/* KeepAwake (PS4) - same as the PS5 build, see ps5.c.
libPS4 has no select(), so the control socket is non-blocking and polled
ten times a second. */
#include "ps4.h"
#ifndef KEEPAWAKE_VERSION
#define KEEPAWAKE_VERSION "dev"
#endif
#define KEEPAWAKE_POLL_MS 100
#define SCE_NET_SO_RCVTIMEO 0x1006
#define SCE_NET_EAGAIN 35
#define SCE_NET_ERROR_EAGAIN 0x80410123
static int (*sceSystemServicePowerTick)(void);
static uint64_t g_started;
static int
resolve_power_tick(void) {
int handle = sceKernelLoadStartModule("/system/common/lib/libSceSystemService.sprx",
0, 0, 0, NULL, NULL);
if(handle < 0) {
return -1;
}
RESOLVE(handle, sceSystemServicePowerTick);
return sceSystemServicePowerTick ? 0 : -1;
}
static int
would_block(int rc) {
// Only a plain -1 leaves the reason in errno; anything else is a full code.
return (unsigned int)rc == SCE_NET_ERROR_EAGAIN ||
(rc == -1 && sce_net_errno == SCE_NET_EAGAIN);
}
#define WEB_CONSOLE "PS4"
#include "web.h"
static int
web_recv(int fd, void *buf, size_t len) {
int rc = sceNetRecv(fd, buf, len, 0);
if(rc < 0) {
return would_block(rc) ? WEB_AGAIN : WEB_ERROR;
}
return rc;
}
static int
web_send(int fd, const void *buf, size_t len) {
int rc = sceNetSend(fd, buf, len, 0);
if(rc < 0) {
return would_block(rc) ? WEB_AGAIN : WEB_ERROR;
}
return rc;
}
static void
web_sleep_ms(int ms) {
sceKernelUsleep(ms * 1000);
}
static long long
web_uptime_ms(void) {
return (sceKernelGetProcessTime() - g_started) / 1000;
}
static long long
web_uptime(void) {
return web_uptime_ms() / 1000;
}
/* libPS4 has no getifaddrs(), so read the interface list straight from the
kernel with sysctl(NET_RT_IFLIST). Each interface comes as an if_msghdr
(FreeBSD 9 layout) holding its if_data counters. */
#define KA_CTL_NET 4
#define KA_PF_ROUTE 17
#define KA_NET_RT_IFLIST 3
#define KA_RTM_VERSION 5
#define KA_RTM_IFINFO 0xe
#define KA_IFF_LOOPBACK 0x8
#define KA_IFM_FLAGS 8 // int ifm_flags
#define KA_IFM_DATA 16 // struct if_data ifm_data
#define KA_IFI_DATALEN 7 // u_char ifi_datalen, within if_data
#define KA_IFI_IBYTES 72 // u_long ifi_ibytes, within if_data
#define KA_IFI_OBYTES 80 // u_long ifi_obytes, within if_data
#define KA_IF_DATA_SIZE 152 // sizeof(struct if_data) on FreeBSD 9
static int
web_net_bytes(unsigned long long *rx, unsigned long long *tx) {
static char buf[16384];
int mib[6] = {KA_CTL_NET, KA_PF_ROUTE, 0, 0, KA_NET_RT_IFLIST, 0};
size_t len = sizeof buf;
size_t off;
unsigned short msglen;
unsigned long long v;
int flags;
int found = 0;
*rx = *tx = 0;
if(sysctl(mib, 6, buf, &len, NULL, 0) != 0) {
return -1;
}
for(off = 0; off + 4 <= len; off += msglen) {
const unsigned char *m = (const unsigned char*)buf + off;
memcpy(&msglen, m, sizeof msglen);
if(msglen < 4 || off + msglen > len) {
break;
}
if(m[2] != KA_RTM_VERSION || m[3] != KA_RTM_IFINFO) {
continue; // address messages follow each interface; skip them
}
// Only trust the layout if the message is the size we expect.
if(msglen < KA_IFM_DATA + KA_IF_DATA_SIZE ||
m[KA_IFM_DATA + KA_IFI_DATALEN] != KA_IF_DATA_SIZE) {
return -1;
}
memcpy(&flags, m + KA_IFM_FLAGS, sizeof flags);
if(flags & KA_IFF_LOOPBACK) {
continue;
}
memcpy(&v, m + KA_IFM_DATA + KA_IFI_IBYTES, sizeof v);
*rx += v;
memcpy(&v, m + KA_IFM_DATA + KA_IFI_OBYTES, sizeof v);
*tx += v;
found = 1;
}
return found ? 0 : -1;
}
static void
web_get_ip(char *buf, size_t size) {
SceNetCtlInfo info;
buf[0] = 0;
memset(&info, 0, sizeof info);
if(sceNetCtlGetInfo(SCE_NET_CTL_INFO_IP_ADDRESS, &info) == 0) {
snprintf(buf, size, "%s", info.ip_address);
}
}
static void
web_notify(const char *msg) {
printf_notification("%s", msg);
}
static void
make_addr(struct sockaddr_in *addr, unsigned int ip) {
memset(addr, 0, sizeof *addr);
addr->sin_len = sizeof *addr;
addr->sin_family = AF_INET;
addr->sin_port = htons(KEEPAWAKE_PORT);
addr->sin_addr.s_addr = ip;
}
/* On its own thread so a slow browser launch doesn't hold up the page. */
static void*
open_browser_thread(void *arg) {
static char url[64];
UNUSED(arg);
snprintf(url, sizeof url, "http://127.0.0.1:%d/", KEEPAWAKE_PORT);
openBrowser(url);
return NULL;
}
static void
open_browser(void) {
ScePthread thread;
if(scePthreadCreate(&thread, NULL, open_browser_thread, NULL, "keepawake_browser") == 0) {
scePthreadDetach(thread);
}
}
/* Ask an already running instance to quit. Returns 0 if it confirmed. */
static int
quit_running_instance(void) {
struct sockaddr_in addr;
int timeout_us = 3 * 1000 * 1000;
char resp[64];
int fd;
int n;
if((fd = sceNetSocket("keepawake_quit", AF_INET, SOCK_STREAM, 0)) < 0) {
return -1;
}
sceNetSetsockopt(fd, SOL_SOCKET, SCE_NET_SO_RCVTIMEO, &timeout_us, sizeof timeout_us);
make_addr(&addr, IP(127, 0, 0, 1));
if(sceNetConnect(fd, (struct sockaddr*)&addr, sizeof addr) < 0 ||
sceNetSend(fd, web_quit_request, sizeof web_quit_request - 1, 0) < 0) {
sceNetSocketClose(fd);
return -1;
}
n = sceNetRecv(fd, resp, sizeof resp - 1, 0);
sceNetSocketClose(fd);
if(n <= 0) {
return -1;
}
resp[n] = 0;
return strstr(resp, " 200 ") ? 0 : -1;
}
static int
open_control_socket(void) {
struct sockaddr_in addr;
int yes = 1;
int fd;
if((fd = sceNetSocket("keepawake", AF_INET, SOCK_STREAM, 0)) < 0) {
return -1;
}
sceNetSetsockopt(fd, SOL_SOCKET, SCE_NET_SO_REUSEADDR, &yes, sizeof yes);
sceNetSetsockopt(fd, SOL_SOCKET, SO_NBIO, &yes, sizeof yes);
make_addr(&addr, IN_ADDR_ANY);
if(sceNetBind(fd, (struct sockaddr*)&addr, sizeof addr) < 0 ||
sceNetListen(fd, 8) < 0) {
sceNetSocketClose(fd);
return -1;
}
return fd;
}
int
_main(struct thread *td) {
char ip[32];
int yes = 1;
int srv;
int cli;
int quit;
UNUSED(td);
initKernel();
initLibc();
initNetwork();
initPthread();
initSysUtil();
jailbreak();
sceNetCtlInit();
if(resolve_power_tick() != 0) {
printf_notification("Keep Awake failed to start:\n"
"sceSystemServicePowerTick not found");
return -1;
}
if((srv = open_control_socket()) < 0) {
// Port taken: an instance is already running, so close it.
// The running instance shows the "closed" toast itself.
if(quit_running_instance() == 0) {
return 0;
}
printf_notification("Keep Awake failed to start:\n"
"port %d is in use", KEEPAWAKE_PORT);
return -1;
}
g_started = sceKernelGetProcessTime();
ka_load_settings();
web_get_ip(ip, sizeof ip);
if(ip[0]) {
printf_notification("Keep Awake " KEEPAWAKE_VERSION " enabled\n"
"Control it at http://%s:%d", ip, KEEPAWAKE_PORT);
} else {
printf_notification("Keep Awake " KEEPAWAKE_VERSION " enabled\n"
"Control page on port %d", KEEPAWAKE_PORT);
}
if(ka_open_on_start) {
open_browser();
}
while(1) {
ka_tick();
if((cli = sceNetAccept(srv, NULL, NULL)) >= 0) {
sceNetSetsockopt(cli, SOL_SOCKET, SO_NBIO, &yes, sizeof yes);
quit = web_handle_client(cli);
sceNetSocketClose(cli);
if(quit) {
printf_notification("Keep Awake " KEEPAWAKE_VERSION " closed");
sceNetSocketClose(srv);
return 0;
}
continue;
}
sceKernelUsleep(KEEPAWAKE_POLL_MS * 1000);
}
}
+328
View File
@@ -0,0 +1,328 @@
/* KeepAwake - keep the PS5 out of rest mode while this payload is running.
The idle timer is reset by calling sceSystemServicePowerTick() every few
seconds. A control page is served on TCP port 9031 (see web.h). Holding
that port also means only one instance runs, and a second copy of the
payload asks the running one to quit. */
#include <arpa/inet.h>
#include <errno.h>
#include <fcntl.h>
#include <ifaddrs.h>
#include <net/if.h>
#include <netinet/in.h>
#include <pthread.h>
#include <signal.h>
#include <stdarg.h>
#include <stdio.h>
#include <string.h>
#include <sys/select.h>
#include <sys/socket.h>
#include <time.h>
#include <unistd.h>
#ifndef KEEPAWAKE_VERSION
#define KEEPAWAKE_VERSION "dev"
#endif
typedef struct notify_request {
char useless1[45];
char message[3075];
} notify_request_t;
int sceKernelSendNotificationRequest(int, notify_request_t*, size_t, int);
int sceSystemServicePowerTick(void);
int sceSystemServiceLaunchWebBrowser(const char *uri, void *param);
int sceUserServiceInitialize(void *param);
static long long g_started_ms;
static long long
now_ms(void) {
struct timespec ts;
clock_gettime(CLOCK_MONOTONIC, &ts);
return (long long)ts.tv_sec * 1000 + ts.tv_nsec / 1000000;
}
static void
notify(const char *fmt, ...) {
notify_request_t req;
va_list args;
bzero(&req, sizeof req);
va_start(args, fmt);
vsnprintf(req.message, sizeof req.message, fmt, args);
va_end(args);
printf("[KeepAwake] %s\n", req.message);
fflush(stdout);
sceKernelSendNotificationRequest(0, &req, sizeof req, 0);
}
#define WEB_CONSOLE "PS5"
#include "web.h"
static int
web_recv(int fd, void *buf, size_t len) {
ssize_t rc = recv(fd, buf, len, 0);
if(rc < 0) {
return (errno == EAGAIN || errno == EWOULDBLOCK) ? WEB_AGAIN : WEB_ERROR;
}
return rc;
}
static int
web_send(int fd, const void *buf, size_t len) {
ssize_t rc = send(fd, buf, len, 0);
if(rc < 0) {
return (errno == EAGAIN || errno == EWOULDBLOCK) ? WEB_AGAIN : WEB_ERROR;
}
return rc;
}
static void
web_sleep_ms(int ms) {
usleep(ms * 1000);
}
static long long
web_uptime_ms(void) {
return now_ms() - g_started_ms;
}
static long long
web_uptime(void) {
return web_uptime_ms() / 1000;
}
static int
web_net_bytes(unsigned long long *rx, unsigned long long *tx) {
struct ifaddrs *ifs;
struct ifaddrs *it;
struct if_data *data;
int found = 0;
*rx = *tx = 0;
if(getifaddrs(&ifs) != 0) {
return -1;
}
// The link-level (AF_LINK) entries carry each interface's counters.
for(it = ifs; it; it = it->ifa_next) {
if(!it->ifa_addr || it->ifa_addr->sa_family != AF_LINK || !it->ifa_data ||
(it->ifa_flags & IFF_LOOPBACK)) {
continue;
}
data = it->ifa_data;
*rx += data->ifi_ibytes;
*tx += data->ifi_obytes;
found = 1;
}
freeifaddrs(ifs);
return found ? 0 : -1;
}
static void
web_get_ip(char *buf, size_t size) {
struct ifaddrs *ifs;
struct ifaddrs *it;
buf[0] = 0;
if(getifaddrs(&ifs) != 0) {
return;
}
for(it = ifs; it; it = it->ifa_next) {
if(!it->ifa_addr || it->ifa_addr->sa_family != AF_INET ||
!(it->ifa_flags & IFF_UP) || (it->ifa_flags & IFF_LOOPBACK)) {
continue;
}
inet_ntop(AF_INET, &((struct sockaddr_in*)it->ifa_addr)->sin_addr,
buf, size);
break;
}
freeifaddrs(ifs);
}
static void
web_notify(const char *msg) {
notify("%s", msg);
}
/* On its own thread so a slow browser launch doesn't hold up the page. */
static void*
open_browser_thread(void *arg) {
char url[64];
snprintf(url, sizeof url, "http://127.0.0.1:%d/", KEEPAWAKE_PORT);
sceUserServiceInitialize(0);
if(sceSystemServiceLaunchWebBrowser(url, 0) != 0) {
notify("Keep Awake couldn't open the browser");
}
return NULL;
}
static void
open_browser(void) {
pthread_t thread;
if(pthread_create(&thread, NULL, open_browser_thread, NULL) == 0) {
pthread_detach(thread);
}
}
/* Ask an already running instance to quit. Returns 0 if it confirmed. */
static int
quit_running_instance(void) {
struct sockaddr_in addr;
struct timeval tv = {3, 0};
char resp[64];
ssize_t n;
int fd;
if((fd = socket(AF_INET, SOCK_STREAM, 0)) < 0) {
return -1;
}
setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof tv);
bzero(&addr, sizeof addr);
addr.sin_family = AF_INET;
addr.sin_port = htons(KEEPAWAKE_PORT);
addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
if(connect(fd, (struct sockaddr*)&addr, sizeof addr) != 0 ||
send(fd, web_quit_request, sizeof web_quit_request - 1, 0) < 0) {
close(fd);
return -1;
}
n = recv(fd, resp, sizeof resp - 1, 0);
close(fd);
if(n <= 0) {
return -1;
}
resp[n] = 0;
return strstr(resp, " 200 ") ? 0 : -1;
}
static int
open_control_socket(void) {
struct sockaddr_in addr;
int yes = 1;
int fd;
if((fd = socket(AF_INET, SOCK_STREAM, 0)) < 0) {
return -1;
}
setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &yes, sizeof yes);
bzero(&addr, sizeof addr);
addr.sin_family = AF_INET;
addr.sin_port = htons(KEEPAWAKE_PORT);
addr.sin_addr.s_addr = htonl(INADDR_ANY);
if(bind(fd, (struct sockaddr*)&addr, sizeof addr) != 0 ||
listen(fd, 8) != 0) {
int err = errno;
close(fd);
errno = err;
return -1;
}
return fd;
}
int
main(void) {
struct timeval tv;
char ip[32];
fd_set fds;
int srv;
int cli;
int rc;
signal(SIGPIPE, SIG_IGN);
if((srv = open_control_socket()) < 0) {
if(errno == EADDRINUSE) {
// Port taken: an instance is already running, so close it.
// The running instance shows the "closed" toast itself.
if(quit_running_instance() == 0) {
return 0;
}
notify("Keep Awake failed to start:\nport %d is in use", KEEPAWAKE_PORT);
return -1;
}
notify("Keep Awake failed to start: %s", strerror(errno));
return -1;
}
g_started_ms = now_ms();
ka_load_settings();
web_get_ip(ip, sizeof ip);
if(ip[0]) {
notify("Keep Awake " KEEPAWAKE_VERSION " enabled\n"
"Control it at http://%s:%d", ip, KEEPAWAKE_PORT);
} else {
notify("Keep Awake " KEEPAWAKE_VERSION " enabled\n"
"Control page on port %d", KEEPAWAKE_PORT);
}
if(ka_open_on_start) {
open_browser();
}
while(1) {
FD_ZERO(&fds);
FD_SET(srv, &fds);
tv.tv_sec = ka_tick();
tv.tv_usec = 0;
rc = select(srv + 1, &fds, NULL, NULL, &tv);
if(rc < 0) {
if(errno == EINTR) {
continue;
}
notify("Keep Awake stopped: %s", strerror(errno));
break;
}
if(rc > 0 && FD_ISSET(srv, &fds)) {
if((cli = accept(srv, NULL, NULL)) < 0) {
if(errno != EINTR && errno != ECONNABORTED) {
usleep(100 * 1000); // e.g. out of descriptors: don't spin
}
continue;
}
fcntl(cli, F_SETFL, fcntl(cli, F_GETFL) | O_NONBLOCK);
rc = web_handle_client(cli);
close(cli);
if(rc) {
notify("Keep Awake " KEEPAWAKE_VERSION " closed");
break;
}
}
}
close(srv);
return 0;
}
+758
View File
@@ -0,0 +1,758 @@
/* State and tiny HTTP server for the KeepAwake control page, shared by the
PS5 and PS4 builds.
Keep Awake is either on (resetting the idle timer) or off (payload still
running, so the page can turn it back on). When on it runs in one of
three modes:
always no time limit
timer for a set time, then it turns itself off
auto only while the network is busy (download, FTP, PKG install),
plus a quiet period after it stops
Routes:
GET / the control page (src/index.html)
GET /status JSON status
POST /on keep the console awake with no time limit
POST /on?minutes=N keep it awake for N minutes, then turn off
POST /on?auto=1 keep it awake only while transferring
POST /off stop keeping it awake, but stay running
POST /quit end the payload
POST /settings?open_on_start=0|1&auto_threshold_kb=N&auto_quiet_minutes=N
change any of the settings (saved to
KEEPAWAKE_SETTINGS)
POSTs that carry an Origin header from another host are refused, so a web
page open elsewhere on the network can't drive the console.
The platform source defines WEB_CONSOLE ("PS5" or "PS4") before including
this file and implements the functions declared below. Client sockets must
be non-blocking. */
#include "index_html.h"
#define KEEPAWAKE_PORT 9031
#define KEEPAWAKE_TICK_SECONDS 10
#define WEB_AGAIN -1
#define WEB_ERROR -2
// Requests are handled one at a time, so don't let a slow client hold on long.
#define WEB_TIMEOUT_MS 1000
#define WEB_POLL_MS 10
/* Bytes read, 0 if the client closed, WEB_AGAIN if no data yet, or WEB_ERROR. */
static int web_recv(int fd, void *buf, size_t len);
/* Bytes sent, WEB_AGAIN if the socket is full, or WEB_ERROR. */
static int web_send(int fd, const void *buf, size_t len);
static void web_sleep_ms(int ms);
/* Time since the payload started. */
static long long web_uptime(void);
static long long web_uptime_ms(void);
/* The console's LAN IP address, or an empty string if unknown. */
static void web_get_ip(char *buf, size_t size);
/* Bytes received and sent on all non-loopback interfaces. Returns -1 if the
counters can't be read. */
static int web_net_bytes(unsigned long long *rx, unsigned long long *tx);
/* Show a toast on the console. */
static void web_notify(const char *msg);
#define KA_MAX_MINUTES (7 * 24 * 60)
#ifndef KEEPAWAKE_SETTINGS
#define KEEPAWAKE_SETTINGS "/data/keepawake.cfg"
#endif
static int ka_active = 1; // resetting the idle timer?
static long long ka_changed = 0; // web_uptime() when on/off last changed
static long long ka_next_tick = 0; // web_uptime() when the next tick is due
static long long ka_until = 0; // web_uptime() when the timer ends, 0 if none
static long long ka_minutes = 0; // length of the current timer
static int ka_auto = 0; // on only while transferring?
// Settings, saved to KEEPAWAKE_SETTINGS.
static int ka_open_on_start = 1; // open the page in the console's browser at start
static long long ka_threshold_kb = 500; // speed that counts as transferring, KB/s
static long long ka_quiet_minutes = 5; // stay awake this long after a transfer stops
#define KA_MIN_THRESHOLD_KB 10
#define KA_MAX_THRESHOLD_KB (100 * 1024)
#define KA_MIN_QUIET_MINUTES 1
#define KA_MAX_QUIET_MINUTES 120
/* Find "key=<number>" at the start of a line. Returns 1 if found. */
static int
ka_cfg_number(const char *buf, const char *key, long long *value) {
const char *p = buf;
size_t k = strlen(key);
long long n = 0;
int digits = 0;
// libPS4's strstr() takes plain char pointers.
while((p = strstr((char*)p, (char*)key))) {
if((p == buf || p[-1] == '\n') && p[k] == '=') {
for(p += k + 1; *p >= '0' && *p <= '9'; p++) {
if(++digits > 9) {
return 0;
}
n = n * 10 + (*p - '0');
}
if(digits) {
*value = n;
}
return digits > 0;
}
p += k;
}
return 0;
}
static void
ka_load_settings(void) {
char buf[256];
long long v;
int fd;
int n;
if((fd = open(KEEPAWAKE_SETTINGS, O_RDONLY, 0)) < 0) {
return; // no file yet: keep the defaults
}
n = read(fd, buf, sizeof buf - 1);
close(fd);
if(n <= 0) {
return;
}
buf[n] = 0;
if(ka_cfg_number(buf, "open_on_start", &v) && v <= 1) {
ka_open_on_start = (int)v;
}
if(ka_cfg_number(buf, "auto_threshold_kb", &v) &&
v >= KA_MIN_THRESHOLD_KB && v <= KA_MAX_THRESHOLD_KB) {
ka_threshold_kb = v;
}
if(ka_cfg_number(buf, "auto_quiet_minutes", &v) &&
v >= KA_MIN_QUIET_MINUTES && v <= KA_MAX_QUIET_MINUTES) {
ka_quiet_minutes = v;
}
}
static int
ka_save_settings(void) {
char buf[160];
int fd;
int n;
int ok;
n = snprintf(buf, sizeof buf,
"open_on_start=%d\n"
"auto_threshold_kb=%lld\n"
"auto_quiet_minutes=%lld\n",
ka_open_on_start, ka_threshold_kb, ka_quiet_minutes);
if((fd = open(KEEPAWAKE_SETTINGS, O_WRONLY | O_CREAT | O_TRUNC, 0644)) < 0) {
return -1;
}
ok = write(fd, buf, n) == n;
close(fd);
return ok ? 0 : -1;
}
/* Transfer detection: the interface byte counters are sampled every
KA_SAMPLE_MS and the speed is the change over the last KA_SAMPLES samples
(about 10 seconds), which smooths out bursty traffic. The network counts
as busy while either direction is at or above the threshold. */
#define KA_SAMPLES 6
#define KA_SAMPLE_MS 2000
static struct {
long long ms;
unsigned long long rx;
unsigned long long tx;
} ka_samples[KA_SAMPLES];
static int ka_nsamples = 0;
static int ka_sample_pos = 0; // where the next sample goes
static long long ka_next_sample_ms = 0;
static int ka_net_ok = 0; // counters readable?
static long long ka_rx_rate = 0; // bytes per second
static long long ka_tx_rate = 0;
static long long ka_last_busy = -1; // web_uptime() when last busy, -1 if never
static int ka_auto_awake = 0; // auto mode currently keeping it awake?
static int
ka_transferring(void) {
long long rate = ka_rx_rate > ka_tx_rate ? ka_rx_rate : ka_tx_rate;
return ka_net_ok && rate >= ka_threshold_kb * 1024;
}
static void
ka_sample(void) {
unsigned long long rx;
unsigned long long tx;
long long ms = web_uptime_ms();
int newest;
int oldest;
if(ms < ka_next_sample_ms) {
return;
}
ka_next_sample_ms = ms + KA_SAMPLE_MS;
if(web_net_bytes(&rx, &tx) != 0) {
ka_net_ok = 0;
ka_rx_rate = ka_tx_rate = 0;
return;
}
ka_net_ok = 1;
// Counters that go backwards were reset (or wrapped): start over.
if(ka_nsamples) {
newest = (ka_sample_pos + KA_SAMPLES - 1) % KA_SAMPLES;
if(rx < ka_samples[newest].rx || tx < ka_samples[newest].tx) {
ka_nsamples = 0;
}
}
ka_samples[ka_sample_pos].ms = ms;
ka_samples[ka_sample_pos].rx = rx;
ka_samples[ka_sample_pos].tx = tx;
ka_sample_pos = (ka_sample_pos + 1) % KA_SAMPLES;
if(ka_nsamples < KA_SAMPLES) {
ka_nsamples++;
}
newest = (ka_sample_pos + KA_SAMPLES - 1) % KA_SAMPLES;
oldest = (ka_sample_pos + KA_SAMPLES - ka_nsamples) % KA_SAMPLES;
if(ka_nsamples >= 2 && ka_samples[newest].ms > ka_samples[oldest].ms) {
long long span = ka_samples[newest].ms - ka_samples[oldest].ms;
ka_rx_rate = (long long)(ka_samples[newest].rx - ka_samples[oldest].rx) * 1000 / span;
ka_tx_rate = (long long)(ka_samples[newest].tx - ka_samples[oldest].tx) * 1000 / span;
} else {
ka_rx_rate = ka_tx_rate = 0;
}
if(ka_transferring()) {
ka_last_busy = web_uptime();
}
}
/* Seconds left of the quiet period since the last busy sample. */
static long long
ka_quiet_left(void) {
long long left;
if(ka_last_busy < 0) {
return 0;
}
left = ka_last_busy + ka_quiet_minutes * 60 - web_uptime();
return left > 0 ? left : 0;
}
/* "1 hour 30 minutes", "2 hours", "45 minutes" */
static void
ka_format_minutes(char *buf, size_t size, long long minutes) {
long long h = minutes / 60;
long long m = minutes % 60;
if(h && m) {
snprintf(buf, size, "%lld hour%s %lld minute%s",
h, h == 1 ? "" : "s", m, m == 1 ? "" : "s");
} else if(h) {
snprintf(buf, size, "%lld hour%s", h, h == 1 ? "" : "s");
} else {
snprintf(buf, size, "%lld minute%s", m, m == 1 ? "" : "s");
}
}
static void
ka_set_on(void) {
if(!ka_active) {
ka_active = 1;
ka_changed = web_uptime();
ka_next_tick = 0;
}
}
/* Turn on for the given minutes, or with no time limit (0). Also changes or
removes the timer while already on. */
static void
ka_turn_on(long long minutes) {
int was_plain_on = ka_active && !ka_until && !ka_auto;
char duration[64];
char msg[128];
ka_set_on();
ka_auto = 0;
ka_auto_awake = 0;
ka_until = minutes ? web_uptime() + minutes * 60 : 0;
ka_minutes = minutes;
if(minutes) {
ka_format_minutes(duration, sizeof duration, minutes);
snprintf(msg, sizeof msg, "Keep Awake on for %s", duration);
web_notify(msg);
} else if(!was_plain_on) {
web_notify("Keep Awake turned on\nNo time limit");
}
}
static void
ka_turn_on_auto(void) {
if(ka_active && ka_auto) {
return;
}
ka_set_on();
ka_auto = 1;
ka_until = 0;
ka_minutes = 0;
ka_auto_awake = ka_quiet_left() > 0;
ka_next_tick = 0;
web_notify(ka_auto_awake ?
"Keep Awake on while transferring\nTransfer in progress" :
"Keep Awake on while transferring\nWaiting for a transfer");
}
static void
ka_turn_off(const char *msg) {
if(!ka_active) {
return;
}
ka_active = 0;
ka_until = 0;
ka_auto = 0;
ka_auto_awake = 0;
ka_changed = web_uptime();
web_notify(msg);
}
/* Does the request line start with "<method> <path>", followed by a query or
the end of the path? */
static int
web_route(const char *req, size_t len, const char *method, const char *path) {
size_t m = strlen(method);
size_t p = strlen(path);
char next;
if(len < m + 1 + p + 1 || memcmp(req, method, m) != 0 || req[m] != ' ' ||
memcmp(req + m + 1, path, p) != 0) {
return 0;
}
next = req[m + 1 + p];
return next == ' ' || next == '?';
}
/* Read a whole-number query parameter. Returns 1 and sets *value if it's
there and valid, 0 if absent, -1 if invalid. */
static int
web_query_number(const char *req, size_t len, const char *key, long long *value) {
size_t k = strlen(key);
size_t i;
long long n = 0;
int digits = 0;
// The query runs from '?' to the space before "HTTP/1.x".
for(i = 0; i < len && req[i] != '?' && req[i] != '\r'; i++);
if(i >= len || req[i] != '?') {
return 0;
}
while(i < len && req[i] != ' ' && req[i] != '\r') {
i++; // skip '?' or '&'
if(i + k < len && memcmp(req + i, key, k) == 0 && req[i + k] == '=') {
for(i += k + 1; i < len && req[i] >= '0' && req[i] <= '9'; i++) {
if(++digits > 9) {
return -1;
}
n = n * 10 + (req[i] - '0');
}
if(!digits || (i < len && req[i] != '&' && req[i] != ' ')) {
return -1;
}
*value = n;
return 1;
}
while(i < len && req[i] != '&' && req[i] != ' ' && req[i] != '\r') {
i++;
}
}
return 0;
}
static int
web_send_all(int fd, const void *buf, size_t len) {
const char *p = buf;
int waited = 0;
int rc;
while(len > 0) {
rc = web_send(fd, p, len);
if(rc == WEB_AGAIN) {
if((waited += WEB_POLL_MS) > WEB_TIMEOUT_MS) {
return -1;
}
web_sleep_ms(WEB_POLL_MS);
continue;
}
if(rc <= 0) {
return -1;
}
p += rc;
len -= rc;
}
return 0;
}
static void
web_respond(int fd, const char *status, const char *type,
const void *body, size_t len) {
char head[256];
int n;
n = snprintf(head, sizeof head,
"HTTP/1.1 %s\r\n"
"Content-Type: %s\r\n"
"Content-Length: %d\r\n"
"Cache-Control: no-store\r\n"
"Connection: close\r\n"
"\r\n", status, type, (int)len);
if(web_send_all(fd, head, n) == 0 && len > 0) {
web_send_all(fd, body, len);
}
}
static void
web_respond_json(int fd, const char *status, const char *json) {
web_respond(fd, status, "application/json", json, strlen(json));
}
static void
web_respond_status(int fd) {
long long now = web_uptime();
const char *mode = !ka_active ? "off" : ka_auto ? "auto" : ka_until ? "timer" : "always";
char remaining[24];
char timer[24];
char quiet[24];
char json[768];
char ip[32];
if(ka_active && ka_until) {
snprintf(remaining, sizeof remaining, "%lld", ka_until > now ? ka_until - now : 0);
snprintf(timer, sizeof timer, "%lld", ka_minutes);
} else {
snprintf(remaining, sizeof remaining, "null");
snprintf(timer, sizeof timer, "null");
}
// In auto mode while awake but no longer transferring: time until it rests.
if(ka_active && ka_auto && ka_auto_awake && !ka_transferring()) {
snprintf(quiet, sizeof quiet, "%lld", ka_quiet_left());
} else {
snprintf(quiet, sizeof quiet, "null");
}
web_get_ip(ip, sizeof ip);
snprintf(json, sizeof json,
"{\"version\":\"%s\",\"console\":\"%s\",\"active\":%s,\"mode\":\"%s\","
"\"uptime\":%lld,\"since\":%lld,\"timer\":%s,\"remaining\":%s,"
"\"net\":%s,\"rx_rate\":%lld,\"tx_rate\":%lld,\"transferring\":%s,"
"\"auto_awake\":%s,\"quiet_left\":%s,"
"\"interval\":%d,\"ip\":\"%s\",\"port\":%d,\"open_on_start\":%s,"
"\"auto_threshold_kb\":%lld,\"auto_quiet_minutes\":%lld}",
KEEPAWAKE_VERSION, WEB_CONSOLE, ka_active ? "true" : "false", mode,
now, now - ka_changed, timer, remaining,
ka_net_ok ? "true" : "false", ka_rx_rate, ka_tx_rate,
ka_transferring() ? "true" : "false",
ka_active && ka_auto && ka_auto_awake ? "true" : "false", quiet,
KEEPAWAKE_TICK_SECONDS, ip, KEEPAWAKE_PORT,
ka_open_on_start ? "true" : "false",
ka_threshold_kb, ka_quiet_minutes);
web_respond_json(fd, "200 OK", json);
}
/* POST /settings: any of open_on_start, auto_threshold_kb and
auto_quiet_minutes. Nothing changes unless every given value is valid. */
static void
web_handle_settings(int fd, const char *req, size_t len) {
long long open_on_start = ka_open_on_start;
long long threshold = ka_threshold_kb;
long long quiet = ka_quiet_minutes;
int old_open_on_start = ka_open_on_start;
long long old_threshold = ka_threshold_kb;
long long old_quiet = ka_quiet_minutes;
int got_open = web_query_number(req, len, "open_on_start", &open_on_start);
int got_threshold = web_query_number(req, len, "auto_threshold_kb", &threshold);
int got_quiet = web_query_number(req, len, "auto_quiet_minutes", &quiet);
if(got_open < 0 || got_threshold < 0 || got_quiet < 0 ||
(got_open + got_threshold + got_quiet) == 0 ||
open_on_start > 1 ||
threshold < KA_MIN_THRESHOLD_KB || threshold > KA_MAX_THRESHOLD_KB ||
quiet < KA_MIN_QUIET_MINUTES || quiet > KA_MAX_QUIET_MINUTES) {
web_respond_json(fd, "400 Bad Request",
"{\"error\":\"expected open_on_start=0|1, "
"auto_threshold_kb=10-102400 or auto_quiet_minutes=1-120\"}");
return;
}
ka_open_on_start = (int)open_on_start;
ka_threshold_kb = threshold;
ka_quiet_minutes = quiet;
if(ka_save_settings() != 0) {
// Keep running with what's saved, so the page and the file agree.
ka_open_on_start = old_open_on_start;
ka_threshold_kb = old_threshold;
ka_quiet_minutes = old_quiet;
web_respond_json(fd, "500 Internal Server Error",
"{\"error\":\"couldn't save the settings\"}");
} else {
web_respond_status(fd);
}
}
/* Read the request headers, giving up after WEB_TIMEOUT_MS. Returns the
bytes read. */
static size_t
web_read_request(int fd, char *buf, size_t size) {
size_t len = 0;
int waited = 0;
int rc;
while(len < size - 1) {
rc = web_recv(fd, buf + len, size - 1 - len);
if(rc == WEB_AGAIN) {
if((waited += WEB_POLL_MS) > WEB_TIMEOUT_MS) {
break;
}
web_sleep_ms(WEB_POLL_MS);
continue;
}
if(rc <= 0) {
break;
}
len += rc;
buf[len] = 0;
if(strstr(buf, "\r\n\r\n")) {
break;
}
}
buf[len] = 0;
return len;
}
/* Does the line at p start with "<name>:", ignoring case? */
static int
web_header_is(const char *p, const char *name) {
for(; *name; p++, name++) {
if((*p | 0x20) != (*name | 0x20)) {
return 0;
}
}
return *p == ':';
}
/* Copy the value of a request header into out. Returns 1 if it's present. */
static int
web_header(const char *req, const char *name, char *out, size_t size) {
const char *p = req;
size_t i;
while((p = strstr((char*)p, (char*)"\r\n"))) {
p += 2;
if(web_header_is(p, name)) {
for(p += strlen(name) + 1; *p == ' '; p++);
for(i = 0; *p && *p != '\r' && i < size - 1; i++, p++) {
out[i] = *p;
}
out[i] = 0;
return 1;
}
}
return 0;
}
/* A browser sends Origin on cross-site POSTs. Refuse one that doesn't match
the host it was sent to; curl and the other instance send none. */
static int
web_origin_ok(const char *req) {
char origin[128];
char host[128];
const char *o;
if(!web_header(req, "Origin", origin, sizeof origin)) {
return 1;
}
o = strstr(origin, "://");
return o && web_header(req, "Host", host, sizeof host) &&
strlen(o + 3) == strlen(host) && memcmp(o + 3, host, strlen(host)) == 0;
}
/* Handle one client connection. Returns 1 if the payload should quit. */
static int
web_handle_client(int fd) {
char req[2048];
long long minutes = 0;
long long auto_arg = 0;
size_t len;
int minutes_rc;
int auto_rc;
if(!(len = web_read_request(fd, req, sizeof req))) {
return 0;
}
if(memcmp(req, "POST ", 5) == 0 && !web_origin_ok(req)) {
web_respond_json(fd, "403 Forbidden", "{\"error\":\"cross-origin request\"}");
return 0;
}
if(web_route(req, len, "GET", "/") ||
web_route(req, len, "GET", "/index.html")) {
web_respond(fd, "200 OK", "text/html; charset=utf-8",
index_html, index_html_len);
} else if(web_route(req, len, "GET", "/status")) {
web_respond_status(fd);
} else if(web_route(req, len, "POST", "/on")) {
minutes_rc = web_query_number(req, len, "minutes", &minutes);
auto_rc = web_query_number(req, len, "auto", &auto_arg);
if(minutes_rc < 0 || minutes > KA_MAX_MINUTES || auto_rc < 0 ||
auto_arg > 1 || (auto_arg && minutes)) {
web_respond_json(fd, "400 Bad Request",
"{\"error\":\"use minutes=0-10080 or auto=1\"}");
} else {
if(auto_arg) {
ka_turn_on_auto();
} else {
ka_turn_on(minutes);
}
web_respond_status(fd);
}
} else if(web_route(req, len, "POST", "/off")) {
ka_turn_off("Keep Awake turned off");
web_respond_status(fd);
} else if(web_route(req, len, "POST", "/quit")) {
web_respond_json(fd, "200 OK", "{\"quit\":true}");
return 1;
} else if(web_route(req, len, "POST", "/settings")) {
web_handle_settings(fd, req, len);
} else if(web_route(req, len, "GET", "/on") ||
web_route(req, len, "GET", "/off") ||
web_route(req, len, "GET", "/quit") ||
web_route(req, len, "GET", "/settings")) {
// Only POST changes anything, so link previews and prefetching can't.
web_respond_json(fd, "405 Method Not Allowed",
"{\"error\":\"use POST\"}");
} else if(web_route(req, len, "GET", "/favicon.ico")) {
web_respond(fd, "204 No Content", "text/plain", "", 0);
} else {
web_respond_json(fd, "404 Not Found", "{\"error\":\"not found\"}");
}
return 0;
}
/* Call from the main loop. Samples the network, resets the idle timer when
it's due and handles the timer and auto mode. Returns the seconds until it
next needs calling. */
static int
ka_tick(void) {
long long now = web_uptime();
long long wait;
int awake;
ka_sample();
if(ka_active && ka_until && now >= ka_until) {
ka_turn_off("Keep Awake timer finished\nTurned off");
}
if(!ka_active) {
return KA_SAMPLE_MS / 1000;
}
if(ka_auto) {
awake = ka_quiet_left() > 0;
if(awake && !ka_auto_awake) {
ka_next_tick = 0;
web_notify("Transfer detected\nKeeping the " WEB_CONSOLE " awake");
} else if(!awake && ka_auto_awake) {
web_notify("Transfers finished\nThe " WEB_CONSOLE " can rest again");
}
ka_auto_awake = awake;
if(!awake) {
return KA_SAMPLE_MS / 1000;
}
}
if(now >= ka_next_tick) {
sceSystemServicePowerTick();
ka_next_tick = now + KEEPAWAKE_TICK_SECONDS;
}
wait = ka_next_tick - now;
if(ka_until && ka_until - now < wait) {
wait = ka_until - now;
}
if(wait > KA_SAMPLE_MS / 1000) {
wait = KA_SAMPLE_MS / 1000;
}
return wait > 0 ? (int)wait : 1;
}
/* What a new instance sends to make the running one quit. */
static const char web_quit_request[] =
"POST /quit HTTP/1.0\r\n"
"Host: localhost\r\n"
"Content-Length: 0\r\n"
"\r\n";
+36
View File
@@ -0,0 +1,36 @@
/* Stand-ins for the PS5 system calls, so src/ps5.c can run on a computer
(see `make host`). */
#include <stdio.h>
#include <stddef.h>
int
sceKernelSendNotificationRequest(int device, void *req, size_t size, int blocking) {
(void)device; (void)req; (void)size; (void)blocking;
return 0;
}
int
sceSystemServicePowerTick(void) {
printf("[KeepAwake] power tick\n");
fflush(stdout);
return 0;
}
int
sceUserServiceInitialize(void *param) {
(void)param;
return 0;
}
int
sceSystemServiceLaunchWebBrowser(const char *uri, void *param) {
(void)param;
printf("[KeepAwake] open browser: %s\n", uri);
fflush(stdout);
return 0;
}