Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2930be37ad | |||
|
|
f9225f0d21 | ||
|
|
9d1492b388 | ||
| ad15ae3dfe | |||
| aedbe89654 | |||
| 321c9e014e | |||
| 73092e6ad7 | |||
| 1a3c8a6d4f | |||
| b81d44cfb6 | |||
| 8223cd4445 | |||
| c1187a7926 | |||
| f1f605e180 | |||
| 3625cf48fb | |||
| 680b5d4773 | |||
| 84e4b428e4 | |||
| 768d02c840 |
54
CHANGELOG.md
54
CHANGELOG.md
@@ -7,6 +7,55 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.8.4] - 2026-09-19
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- A Glass theme, light and dark, with frosted see-through panels after Apple's Liquid Glass. It
|
||||||
|
goes solid when the system asks for less transparency or more contrast.
|
||||||
|
- The page can hand playback to a native app. Opened inside an iOS or Android shell, an episode
|
||||||
|
plays through the host's own player instead of the page's, so it keeps going when the screen
|
||||||
|
locks and the car can control it; the player bar, the row buttons and the keyboard shortcuts
|
||||||
|
work as they always did. Video still plays in the page. In a browser nothing changes.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- On a phone, the bottom bar keeps clear of the home indicator, so the seek bar and the times are
|
||||||
|
no longer cut off when the page has the whole screen: in a native shell, or added to the iOS
|
||||||
|
home screen. In landscape the bars keep clear of the notch as well.
|
||||||
|
|
||||||
|
## [0.8.3] - 2026-09-19
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
- The daemon no longer prints the web token when it starts, so it stays out of `docker logs`. It
|
||||||
|
says where the token is kept instead: `[web] token` in config.toml.
|
||||||
|
- Signing in through Cloudflare Access can check the token Access signs: set `access_team` and
|
||||||
|
`access_aud` under `[web]`, and a request has to carry a valid `Cf-Access-Jwt-Assertion` as well as
|
||||||
|
the email header. Without it, anything on the same Docker host as ipx could send the header. See
|
||||||
|
docs/sso.md.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- A Substack post shows its subtitle above the post, as Substack does. Only posts that arrive from
|
||||||
|
now on have it.
|
||||||
|
|
||||||
|
## [0.8.2] - 2026-09-19
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Four themes, each in light and dark: Catppuccin (Latte and Mocha), Gruvbox, Solarized, and High
|
||||||
|
contrast, black and white with every colour at 7:1 or more.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- The themes in Settings are listed in alphabetical order.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Links in Classic, and hints and headings in Modern's dark half, are dark or light enough to
|
||||||
|
read comfortably. A failed-action message is easier to read in every theme.
|
||||||
|
|
||||||
## [0.8.1] - 2026-09-19
|
## [0.8.1] - 2026-09-19
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
@@ -523,7 +572,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- Torrent enclosures through librqbit, seeding to a ratio or a time, with a stall timeout.
|
- Torrent enclosures through librqbit, seeding to a ratio or a time, with a stall timeout.
|
||||||
- `ipx import` and `ipx export` for OPML, and systemd units in `contrib/`.
|
- `ipx import` and `ipx export` for OPML, and systemd units in `contrib/`.
|
||||||
|
|
||||||
[unreleased]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.6.1...main
|
[unreleased]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.8.4...main
|
||||||
|
[0.8.4]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.8.3...v0.8.4
|
||||||
|
[0.8.3]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.8.2...v0.8.3
|
||||||
|
[0.8.2]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.8.1...v0.8.2
|
||||||
[0.8.1]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.8.0...v0.8.1
|
[0.8.1]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.8.0...v0.8.1
|
||||||
[0.8.0]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.7.0...v0.8.0
|
[0.8.0]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.7.0...v0.8.0
|
||||||
[0.7.0]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.6.1...v0.7.0
|
[0.7.0]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.6.1...v0.7.0
|
||||||
|
|||||||
36
CLAUDE.md
36
CLAUDE.md
@@ -18,11 +18,31 @@ Arcane project `content`: `/mnt/fast/arcane/projects/content/compose.yaml`. That
|
|||||||
| Old database | `/mnt/user/ipodderx/state.db`, SQLite, used until the move to Postgres on 2026-09-18 and kept for rollback | `/data/state.db` |
|
| Old database | `/mnt/user/ipodderx/state.db`, SQLite, used until the move to Postgres on 2026-09-18 and kept for rollback | `/data/state.db` |
|
||||||
| Downloads | `/mnt/user/ipodderx/downloads` | `/downloads` |
|
| Downloads | `/mnt/user/ipodderx/downloads` | `/downloads` |
|
||||||
| Web UI | `192.168.1.130:8099`, also `ipodderx.sdf1.net` via a Cloudflare tunnel | `0.0.0.0:8099` |
|
| Web UI | `192.168.1.130:8099`, also `ipodderx.sdf1.net` via a Cloudflare tunnel | `0.0.0.0:8099` |
|
||||||
| Sign-in via the tunnel | Cloudflare Access app `ipodderx`, with Authentik as its identity provider; see [docs/sso.md](docs/sso.md) | trusts `Cf-Access-Authenticated-User-Email` from `192.168.16.1`, the `content_default` gateway |
|
| Sign-in via the tunnel | Cloudflare Access app `ipodderx`, with Authentik as its identity provider; see [docs/sso.md](docs/sso.md) | trusts `Cf-Access-Authenticated-User-Email` from `192.168.16.1`, the `content_default` gateway, and only with a valid `Cf-Access-Jwt-Assertion` (`access_team`, `access_aud`) |
|
||||||
|
|
||||||
Work to do lives in the Gitea issues at https://git.sdf1.net/rays/ipodderx-rs/issues, not in a
|
Work to do lives in the Gitea issues at https://git.sdf1.net/rays/ipodderx-rs/issues, not in a
|
||||||
`TODO.md`. `/src/tea` is logged in: `/src/tea issues list --login git.sdf1.net --repo rays/ipodderx-rs`.
|
`TODO.md`. `/src/tea` is logged in: `/src/tea issues list --login git.sdf1.net --repo rays/ipodderx-rs`.
|
||||||
|
|
||||||
|
**Every problem found gets an issue, before it is fixed.** A bug, a gap or a security hole turned
|
||||||
|
up along the way (reading logs, a review, a test that fails for another reason) is filed as soon
|
||||||
|
as it is found, even if it is fixed a minute later, so there is a record of what was wrong and
|
||||||
|
when. Name the issue in the commit that fixes it (`(#40)` in the subject). Once the fix is on
|
||||||
|
`main` and pushed, comment on the issue with what changed and the commit, then close it:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
R="--login git.sdf1.net --repo rays/ipodderx-rs"
|
||||||
|
t() { timeout 30 /src/tea "$@" < /dev/null; }
|
||||||
|
t issues create $R -t "Web token printed in the startup log" -L bug -d "What is wrong, where, how it was found."
|
||||||
|
t comment $R 40 "Fixed in 3625cf4: the startup line says where the token is kept, not what it is. Deployed in 0.8.3."
|
||||||
|
t issues close $R 40
|
||||||
|
```
|
||||||
|
|
||||||
|
**Give tea a closed stdin and a timeout**, as `t` does. Without a terminal, `tea comment` waits on
|
||||||
|
stdin and never exits; a script closing seven issues sat hung for a day on the second (#39).
|
||||||
|
|
||||||
|
Labels: `bug` for something wrong, `enhancement` for something missing. A problem found and left
|
||||||
|
for later stays open, and that is how it gets picked up again.
|
||||||
|
|
||||||
Deploying a change is: build and push the image, then pull it and recreate the container.
|
Deploying a change is: build and push the image, then pull it and recreate the container.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
@@ -57,8 +77,10 @@ container restarts on its own after a reboot.
|
|||||||
|
|
||||||
Before the container, ipx ran by hand in code-server, with its files in `/config/.config/ipx/` and
|
Before the container, ipx ran by hand in code-server, with its files in `/config/.config/ipx/` and
|
||||||
`/config/.local/share/ipx/`. Those are still there and the container does not read them. If you run
|
`/config/.local/share/ipx/`. Those are still there and the container does not read them. If you run
|
||||||
a daemon by hand for testing, stop it with **`pkill -x ipx`, never `pkill -f ipx`**. `-f` matches
|
a daemon by hand for testing, **stop it by its own PID**: start it with `& echo $! > pid` and
|
||||||
the shell running the command and kills the session (exit 144). This has happened more than once.
|
`kill $(cat pid)`. Never `pkill -x ipx`: Tower sees the container's processes, so it kills
|
||||||
|
production's daemon as well (issue #38). Never `pkill -f ipx` either: `-f` matches the shell
|
||||||
|
running the command and kills the session (exit 144), which has happened more than once.
|
||||||
|
|
||||||
## Before you touch the page
|
## Before you touch the page
|
||||||
|
|
||||||
@@ -100,6 +122,8 @@ Patching that file by guessing an anchor string has failed repeatedly. Read the
|
|||||||
cargo test # ~80 tests: parsing, filters, retention, schedules, SQL, per-user state
|
cargo test # ~80 tests: parsing, filters, retention, schedules, SQL, per-user state
|
||||||
npx tsc -p . # type-checks web/src
|
npx tsc -p . # type-checks web/src
|
||||||
node tests/page-smoke.js
|
node tests/page-smoke.js
|
||||||
|
node tests/native-bridge.js # the page hands playback to a native shell
|
||||||
|
node tests/contrast.js # every theme's palette against WCAG AA
|
||||||
npx playwright test # 40 browser tests against a real daemon on fixture feeds
|
npx playwright test # 40 browser tests against a real daemon on fixture feeds
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -182,10 +206,8 @@ Deliberate simplifications get a `ponytail:` comment naming the ceiling and the
|
|||||||
|
|
||||||
## Known gaps
|
## Known gaps
|
||||||
|
|
||||||
* Cloudflare's `Cf-Access-Jwt-Assertion` is not verified — ipx trusts the hop plus `trusted_proxies`
|
* They are the open issues in Gitea, not a list here: a limitation known and left in place is an
|
||||||
(documented in [docs/sso.md](docs/sso.md)).
|
issue left open.
|
||||||
* A feed's `<description>` subtitle is dropped whenever `content:encoded` exists, which loses
|
|
||||||
Substack-style subtitles.
|
|
||||||
|
|
||||||
<!-- rtk-instructions v2 -->
|
<!-- rtk-instructions v2 -->
|
||||||
# Command output
|
# Command output
|
||||||
|
|||||||
42
Cargo.lock
generated
42
Cargo.lock
generated
@@ -1820,7 +1820,7 @@ checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ipx"
|
name = "ipx"
|
||||||
version = "0.8.1"
|
version = "0.8.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ammonia",
|
"ammonia",
|
||||||
"anyhow",
|
"anyhow",
|
||||||
@@ -1830,6 +1830,7 @@ dependencies = [
|
|||||||
"chrono",
|
"chrono",
|
||||||
"clap",
|
"clap",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
|
"jsonwebtoken",
|
||||||
"librqbit",
|
"librqbit",
|
||||||
"opml",
|
"opml",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
@@ -2007,6 +2008,22 @@ dependencies = [
|
|||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "jsonwebtoken"
|
||||||
|
version = "11.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e75fe14a82d81e5f5af639997db37d8b96045938a7ac6ab18cdbe1c7467e05e1"
|
||||||
|
dependencies = [
|
||||||
|
"aws-lc-rs",
|
||||||
|
"base64 0.22.1",
|
||||||
|
"getrandom 0.2.17",
|
||||||
|
"js-sys",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"signature",
|
||||||
|
"zeroize",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lazy_static"
|
name = "lazy_static"
|
||||||
version = "1.5.0"
|
version = "1.5.0"
|
||||||
@@ -3786,6 +3803,15 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "signature"
|
||||||
|
version = "2.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
|
||||||
|
dependencies = [
|
||||||
|
"rand_core 0.6.4",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "simd-adler32"
|
name = "simd-adler32"
|
||||||
version = "0.3.10"
|
version = "0.3.10"
|
||||||
@@ -5183,6 +5209,20 @@ name = "zeroize"
|
|||||||
version = "1.9.0"
|
version = "1.9.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
|
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
|
||||||
|
dependencies = [
|
||||||
|
"zeroize_derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zeroize_derive"
|
||||||
|
version = "1.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.119",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zerotrie"
|
name = "zerotrie"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "ipx"
|
name = "ipx"
|
||||||
version = "0.8.1"
|
version = "0.8.4"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
@@ -12,6 +12,7 @@ axum = "0.8.9"
|
|||||||
chrono = { version = "0.4.45", default-features = false, features = ["std", "clock"] }
|
chrono = { version = "0.4.45", default-features = false, features = ["std", "clock"] }
|
||||||
clap = { version = "4.6.6", features = ["derive"] }
|
clap = { version = "4.6.6", features = ["derive"] }
|
||||||
futures-util = { version = "0.3.34", default-features = false, features = ["std"] }
|
futures-util = { version = "0.3.34", default-features = false, features = ["std"] }
|
||||||
|
jsonwebtoken = { version = "11.1.0", default-features = false, features = ["aws_lc_rs"] }
|
||||||
librqbit = { version = "9.0.1", default-features = false, features = ["rust-tls", "http-api-client"] }
|
librqbit = { version = "9.0.1", default-features = false, features = ["rust-tls", "http-api-client"] }
|
||||||
opml = "1.1.6"
|
opml = "1.1.6"
|
||||||
percent-encoding = "2.3.2"
|
percent-encoding = "2.3.2"
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ The UI is plain HTTP, so put TLS in front of it if it is reachable from outside
|
|||||||
```sh
|
```sh
|
||||||
cargo test # the engine: parsing, filters, retention, schedules, SQL, per-user state
|
cargo test # the engine: parsing, filters, retention, schedules, SQL, per-user state
|
||||||
node tests/page-smoke.js # the page script loads without throwing
|
node tests/page-smoke.js # the page script loads without throwing
|
||||||
|
node tests/native-bridge.js # the page hands playback to a native shell
|
||||||
npx playwright test # a real browser against a real daemon on fixture feeds
|
npx playwright test # a real browser against a real daemon on fixture feeds
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -141,6 +141,7 @@ before they reach the page.
|
|||||||
```sh
|
```sh
|
||||||
cargo test # parsing, filters, retention, schedules, SQL, per-user isolation
|
cargo test # parsing, filters, retention, schedules, SQL, per-user isolation
|
||||||
node tests/page-smoke.js # the page script loads and every selector it wires at load exists
|
node tests/page-smoke.js # the page script loads and every selector it wires at load exists
|
||||||
|
node tests/native-bridge.js # the page hands playback to a native shell
|
||||||
npx playwright test # a real browser against a real daemon on fixture feeds
|
npx playwright test # a real browser against a real daemon on fixture feeds
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -80,7 +80,9 @@ To kill it, match the binary exactly:
|
|||||||
pkill -x ipx
|
pkill -x ipx
|
||||||
```
|
```
|
||||||
|
|
||||||
`pkill -f ipx` matches the shell running the command too, and kills your own session.
|
`pkill -f ipx` matches the shell running the command too, and kills your own session. On a machine
|
||||||
|
that also runs ipx in a container, `pkill -x ipx` stops that one as well, since the host sees a
|
||||||
|
container's processes: stop the one you started by its PID instead (`kill <pid>`).
|
||||||
|
|
||||||
## Talking to it directly
|
## Talking to it directly
|
||||||
|
|
||||||
|
|||||||
@@ -83,6 +83,8 @@ bind = "0.0.0.0:8099" # 127.0.0.1:8080 by default
|
|||||||
token = "" # generated and saved on first run
|
token = "" # generated and saved on first run
|
||||||
trusted_header = "" # e.g. "Cf-Access-Authenticated-User-Email"
|
trusted_header = "" # e.g. "Cf-Access-Authenticated-User-Email"
|
||||||
trusted_proxies = ["127.0.0.1", "::1"]
|
trusted_proxies = ["127.0.0.1", "::1"]
|
||||||
|
access_team = "" # e.g. "<team>.cloudflareaccess.com"
|
||||||
|
access_aud = "" # the Access application's AUD tag
|
||||||
auto_create_users = true
|
auto_create_users = true
|
||||||
sign_out_url = "" # e.g. "/cdn-cgi/access/logout"
|
sign_out_url = "" # e.g. "/cdn-cgi/access/logout"
|
||||||
session_days = 30
|
session_days = 30
|
||||||
@@ -94,6 +96,9 @@ session_days = 30
|
|||||||
disables that path. See [sso.md](sso.md).
|
disables that path. See [sso.md](sso.md).
|
||||||
* **`trusted_proxies`** — addresses allowed to assert that header, and the entire security boundary
|
* **`trusted_proxies`** — addresses allowed to assert that header, and the entire security boundary
|
||||||
for it. Name the proxy, never a subnet.
|
for it. Name the proxy, never a subnet.
|
||||||
|
* **`access_team`**, **`access_aud`** — with both set, a request through the proxy also has to
|
||||||
|
carry the `Cf-Access-Jwt-Assertion` Cloudflare Access signed for this application, and the name
|
||||||
|
comes from that token instead of the header. See [sso.md](sso.md#verifying-cloudflares-token).
|
||||||
* **`auto_create_users`** — create an account the first time the proxy vouches for a new name.
|
* **`auto_create_users`** — create an account the first time the proxy vouches for a new name.
|
||||||
* **`sign_out_url`** — where Sign out sends someone the proxy signed in: the proxy's own sign-out,
|
* **`sign_out_url`** — where Sign out sends someone the proxy signed in: the proxy's own sign-out,
|
||||||
`/cdn-cgi/access/logout` behind Cloudflare Access. Empty sends them to the sign-in page, where
|
`/cdn-cgi/access/logout` behind Cloudflare Access. Empty sends them to the sign-in page, where
|
||||||
|
|||||||
36
docs/sso.md
36
docs/sso.md
@@ -39,11 +39,17 @@ enabled = true
|
|||||||
bind = "0.0.0.0:8099"
|
bind = "0.0.0.0:8099"
|
||||||
trusted_header = "Cf-Access-Authenticated-User-Email"
|
trusted_header = "Cf-Access-Authenticated-User-Email"
|
||||||
trusted_proxies = ["127.0.0.1", "::1", "192.168.16.1"]
|
trusted_proxies = ["127.0.0.1", "::1", "192.168.16.1"]
|
||||||
|
access_team = "rays-sdf1.cloudflareaccess.com"
|
||||||
|
access_aud = "8bfe73dfbc8c548d1cb5dc11c6db6887bcaf4f5144840396f83a620a140e1c4f"
|
||||||
auto_create_users = true
|
auto_create_users = true
|
||||||
sign_out_url = "/cdn-cgi/access/logout"
|
sign_out_url = "/cdn-cgi/access/logout"
|
||||||
session_days = 30
|
session_days = 30
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The last two turn on the token check described under [Verifying Cloudflare's token](#verifying-cloudflares-token),
|
||||||
|
on since 2026-09-19. Both can be read without the dashboard: a request to the site while signed
|
||||||
|
out is sent to `https://<team domain>/cdn-cgi/access/login/ipodderx.sdf1.net?kid=<AUD tag>&...`.
|
||||||
|
|
||||||
Restart ipx after editing it: `docker compose -f /mnt/fast/arcane/projects/content/compose.yaml
|
Restart ipx after editing it: `docker compose -f /mnt/fast/arcane/projects/content/compose.yaml
|
||||||
restart ipodderx`.
|
restart ipodderx`.
|
||||||
|
|
||||||
@@ -172,9 +178,33 @@ itself, arrive under their own addresses and cannot set the header; the checks a
|
|||||||
sides. Never list a LAN address or range: anyone there could then send
|
sides. Never list a LAN address or range: anyone there could then send
|
||||||
`Cf-Access-Authenticated-User-Email: rays@sdf1.net` and be you.
|
`Cf-Access-Authenticated-User-Email: rays@sdf1.net` and be you.
|
||||||
|
|
||||||
**What ipx does not do:** it does not verify Cloudflare's signed `Cf-Access-Jwt-Assertion`. It
|
**Unless the token is checked.** With `access_team` and `access_aud` set (next section), the
|
||||||
trusts the hop. Verifying the signature would make the containers on Tower irrelevant to the
|
header is not enough on its own: the request has to carry the token Cloudflare Access signed, and
|
||||||
boundary, and is the upgrade if that ever matters.
|
a container on Tower cannot make one.
|
||||||
|
|
||||||
|
### Verifying Cloudflare's token
|
||||||
|
|
||||||
|
Access adds `Cf-Access-Jwt-Assertion` to every request it forwards: a JWT naming the person,
|
||||||
|
signed with keys only Cloudflare holds. With these two settings ipx checks it on every proxied
|
||||||
|
request, and takes the name from its `email` claim.
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[web]
|
||||||
|
access_team = "<team>.cloudflareaccess.com" # Zero Trust → Settings: the team domain
|
||||||
|
access_aud = "…" # Access → Applications → ipodderx → Overview: Application Audience (AUD) Tag
|
||||||
|
```
|
||||||
|
|
||||||
|
ipx fetches the public keys from `https://<access_team>/cdn-cgi/access/certs` when it starts, and
|
||||||
|
again when a token names a key it has not seen (Cloudflare rotates them every six weeks or so), at
|
||||||
|
most once a minute. It checks the signature (RS256 only), that the audience is this application's
|
||||||
|
tag, the issuer, and the expiry. Anything else is refused, and so is every proxied request while
|
||||||
|
the keys cannot be fetched; password and token sign-in still work then.
|
||||||
|
|
||||||
|
`trusted_header` and `trusted_proxies` still apply: the check is added to them, not put in their
|
||||||
|
place.
|
||||||
|
|
||||||
|
Check it: the busybox request under [Check it](#check-it), which sends the email header without a
|
||||||
|
token from the Docker bridge, now gets `sign in`, and the site still signs you in through Authentik.
|
||||||
|
|
||||||
**Turning it off:** clear `trusted_header` and restart. Proxy-made accounts stay, but nobody can sign
|
**Turning it off:** clear `trusted_header` and restart. Proxy-made accounts stay, but nobody can sign
|
||||||
in with them until they are given a password (`ipx user passwd <name>`).
|
in with them until they are given a password (`ipx user passwd <name>`).
|
||||||
|
|||||||
215
src/access.rs
Normal file
215
src/access.rs
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
//! Cloudflare Access's signed assertion, `Cf-Access-Jwt-Assertion`. Without it, the proxy
|
||||||
|
//! sign-in trusts a plain header from any address in `trusted_proxies`, and on Tower that
|
||||||
|
//! address is the Docker gateway: any container there could send the header and be anyone.
|
||||||
|
//! Access signs the same identity with keys only Cloudflare holds, so checking that signature
|
||||||
|
//! takes the network out of the question.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::future::Future;
|
||||||
|
use std::sync::Mutex;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use jsonwebtoken::jwk::JwkSet;
|
||||||
|
use jsonwebtoken::{Algorithm, DecodingKey, Validation, decode, decode_header};
|
||||||
|
|
||||||
|
/// Cloudflare rotates its keys every six weeks or so, publishing the new one before using it.
|
||||||
|
/// A token naming a key not seen yet refetches, but no more often than this, so a stream of
|
||||||
|
/// made-up key ids cannot turn every request into a request to Cloudflare.
|
||||||
|
const REFETCH_EVERY: Duration = Duration::from_secs(60);
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct Keys {
|
||||||
|
cache: Mutex<Cache>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct Cache {
|
||||||
|
keys: HashMap<String, DecodingKey>,
|
||||||
|
fetched: Option<Instant>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct Claims {
|
||||||
|
email: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Keys {
|
||||||
|
/// The name the token vouches for, or None: a bad signature, the wrong audience or issuer, an
|
||||||
|
/// expired token, a key that cannot be had, or no email in it (a service token has none).
|
||||||
|
pub async fn verify(&self, client: &reqwest::Client, team: &str, aud: &str, token: &str) -> Option<String> {
|
||||||
|
self.verify_with(team, aud, token, || fetch(client, team)).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fill the cache before the first request needs it. A failure is only logged: the next
|
||||||
|
/// request tries again, and until one succeeds the proxy sign-in refuses everyone.
|
||||||
|
pub async fn prefetch(&self, client: &reqwest::Client, team: &str) {
|
||||||
|
match fetch(client, team).await {
|
||||||
|
Ok(set) => self.store(set),
|
||||||
|
Err(e) => tracing::warn!(error = %format!("{e:#}"), "could not fetch Cloudflare Access's signing keys"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn verify_with<F, Fut>(&self, team: &str, aud: &str, token: &str, fetch: F) -> Option<String>
|
||||||
|
where
|
||||||
|
F: FnOnce() -> Fut,
|
||||||
|
Fut: Future<Output = Result<JwkSet>>,
|
||||||
|
{
|
||||||
|
let kid = decode_header(token).ok()?.kid?;
|
||||||
|
let key = match self.key(&kid) {
|
||||||
|
Some(k) => k,
|
||||||
|
None => {
|
||||||
|
if !self.may_refetch() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
match fetch().await {
|
||||||
|
Ok(set) => self.store(set),
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(error = %format!("{e:#}"), "could not fetch Cloudflare Access's signing keys");
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.key(&kid)?
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// RS256 only: a token that names HS256 or none is refused here, before its signature
|
||||||
|
// is looked at, rather than checked with the public key as if it were a secret.
|
||||||
|
let mut v = Validation::new(Algorithm::RS256);
|
||||||
|
v.set_audience(&[aud]);
|
||||||
|
v.set_issuer(&[format!("https://{team}")]);
|
||||||
|
v.validate_nbf = true;
|
||||||
|
match decode::<Claims>(token, &key, &v) {
|
||||||
|
Ok(data) => crate::auth::name_from_header(&data.claims.email?),
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(error = %e, "refused a Cloudflare Access token");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn key(&self, kid: &str) -> Option<DecodingKey> {
|
||||||
|
self.cache.lock().unwrap().keys.get(kid).cloned()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Takes the slot as it answers, so two requests at once do not both fetch.
|
||||||
|
fn may_refetch(&self) -> bool {
|
||||||
|
let mut c = self.cache.lock().unwrap();
|
||||||
|
if c.fetched.is_some_and(|t| t.elapsed() < REFETCH_EVERY) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
c.fetched = Some(Instant::now());
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replaces the whole set, so a key Cloudflare has retired stops being accepted.
|
||||||
|
fn store(&self, set: JwkSet) {
|
||||||
|
let keys = set
|
||||||
|
.keys
|
||||||
|
.iter()
|
||||||
|
.filter_map(|k| Some((k.common.key_id.clone()?, DecodingKey::from_jwk(k).ok()?)))
|
||||||
|
.collect();
|
||||||
|
let mut c = self.cache.lock().unwrap();
|
||||||
|
c.keys = keys;
|
||||||
|
c.fetched = Some(Instant::now());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fetch(client: &reqwest::Client, team: &str) -> Result<JwkSet> {
|
||||||
|
let url = format!("https://{team}/cdn-cgi/access/certs");
|
||||||
|
client
|
||||||
|
.get(&url)
|
||||||
|
.timeout(Duration::from_secs(10))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("fetching {url}"))?
|
||||||
|
.error_for_status()?
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.context("reading the signing keys")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use jsonwebtoken::{EncodingKey, Header, encode, get_current_timestamp};
|
||||||
|
|
||||||
|
const TEAM: &str = "team.cloudflareaccess.com";
|
||||||
|
const AUD: &str = "aud-tag";
|
||||||
|
|
||||||
|
fn jwks() -> JwkSet {
|
||||||
|
serde_json::from_str(include_str!("../tests/data/access-test.jwks.json")).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn token(key: &[u8], alg: Algorithm, claims: serde_json::Value) -> String {
|
||||||
|
let mut h = Header::new(alg);
|
||||||
|
h.kid = Some("k1".into());
|
||||||
|
let k = if alg == Algorithm::RS256 { EncodingKey::from_rsa_der(key) } else { EncodingKey::from_secret(key) };
|
||||||
|
encode(&h, &claims, &k).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn claims(aud: &str, exp_in: i64) -> serde_json::Value {
|
||||||
|
let now = get_current_timestamp() as i64;
|
||||||
|
serde_json::json!({
|
||||||
|
"aud": [aud], "iss": format!("https://{TEAM}"), "email": "Rays@SDF1.net",
|
||||||
|
"iat": now, "nbf": now, "exp": now + exp_in, "type": "app",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const SIGNER: &[u8] = include_bytes!("../tests/data/access-test.der");
|
||||||
|
const FORGER: &[u8] = include_bytes!("../tests/data/access-forger.der");
|
||||||
|
|
||||||
|
async fn check(keys: &Keys, t: &str) -> Option<String> {
|
||||||
|
keys.verify_with(TEAM, AUD, t, || async { Ok(jwks()) }).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn only_a_token_cloudflare_signed_for_this_app_signs_anyone_in() {
|
||||||
|
let keys = Keys::default();
|
||||||
|
keys.store(jwks());
|
||||||
|
let ok = token(SIGNER, Algorithm::RS256, claims(AUD, 300));
|
||||||
|
assert_eq!(check(&keys, &ok).await.as_deref(), Some("rays@sdf1.net"), "lower-cased like the header");
|
||||||
|
|
||||||
|
let other_app = token(SIGNER, Algorithm::RS256, claims("another-app", 300));
|
||||||
|
assert_eq!(check(&keys, &other_app).await, None, "an Access token for another application");
|
||||||
|
|
||||||
|
let expired = token(SIGNER, Algorithm::RS256, claims(AUD, -3600));
|
||||||
|
assert_eq!(check(&keys, &expired).await, None, "expired");
|
||||||
|
|
||||||
|
let forged = token(FORGER, Algorithm::RS256, claims(AUD, 300));
|
||||||
|
assert_eq!(check(&keys, &forged).await, None, "signed by a key that is not Cloudflare's");
|
||||||
|
|
||||||
|
// HMAC and none, the classic ways to get a token past a verifier that trusts its header.
|
||||||
|
let hs = token(b"any secret at all", Algorithm::HS256, claims(AUD, 300));
|
||||||
|
assert_eq!(check(&keys, &hs).await, None, "HS256");
|
||||||
|
let none = format!("{}.{}.", "eyJhbGciOiJub25lIiwia2lkIjoiazEifQ",
|
||||||
|
ok.split('.').nth(1).unwrap());
|
||||||
|
assert_eq!(check(&keys, &none).await, None, "alg none");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn an_unknown_key_refetches_once_a_minute_at_most() {
|
||||||
|
let keys = Keys::default();
|
||||||
|
let ok = token(SIGNER, Algorithm::RS256, claims(AUD, 300));
|
||||||
|
let fetches = std::sync::atomic::AtomicUsize::new(0);
|
||||||
|
let count = || { fetches.fetch_add(1, std::sync::atomic::Ordering::SeqCst); async { Ok(jwks()) } };
|
||||||
|
assert_eq!(keys.verify_with(TEAM, AUD, &ok, count).await.as_deref(), Some("rays@sdf1.net"),
|
||||||
|
"a key not cached yet is fetched");
|
||||||
|
assert_eq!(fetches.load(std::sync::atomic::Ordering::SeqCst), 1);
|
||||||
|
|
||||||
|
// A made-up key id straight after: not fetched again.
|
||||||
|
let mut h = Header::new(Algorithm::RS256);
|
||||||
|
h.kid = Some("nobody".into());
|
||||||
|
let stray = encode(&h, &claims(AUD, 300), &EncodingKey::from_rsa_der(SIGNER)).unwrap();
|
||||||
|
let count = || { fetches.fetch_add(1, std::sync::atomic::Ordering::SeqCst); async { Ok(jwks()) } };
|
||||||
|
assert_eq!(keys.verify_with(TEAM, AUD, &stray, count).await, None);
|
||||||
|
assert_eq!(fetches.load(std::sync::atomic::Ordering::SeqCst), 1, "rate-limited");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn keys_that_cannot_be_fetched_refuse_rather_than_wave_through() {
|
||||||
|
let keys = Keys::default();
|
||||||
|
let ok = token(SIGNER, Algorithm::RS256, claims(AUD, 300));
|
||||||
|
let got = keys.verify_with(TEAM, AUD, &ok, || async { Err(anyhow::anyhow!("offline")) }).await;
|
||||||
|
assert_eq!(got, None);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -78,6 +78,14 @@ pub struct Web {
|
|||||||
/// hop that set it, so an empty list means nobody: on a LAN-bound port anyone could
|
/// hop that set it, so an empty list means nobody: on a LAN-bound port anyone could
|
||||||
/// otherwise claim to be anyone. Loopback covers a tunnel running beside the daemon.
|
/// otherwise claim to be anyone. Loopback covers a tunnel running beside the daemon.
|
||||||
pub trusted_proxies: Vec<String>,
|
pub trusted_proxies: Vec<String>,
|
||||||
|
/// Cloudflare Access's team domain, `<team>.cloudflareaccess.com`. With `access_aud`, the
|
||||||
|
/// proxy sign-in also needs the `Cf-Access-Jwt-Assertion` Access signs, and takes the name
|
||||||
|
/// from it: a header from a trusted address is otherwise all it asks for, and on a Docker
|
||||||
|
/// host any container can send one from the gateway's address.
|
||||||
|
pub access_team: String,
|
||||||
|
/// The Access application's Application Audience (AUD) tag. Empty, with `access_team`,
|
||||||
|
/// leaves the signature unchecked.
|
||||||
|
pub access_aud: String,
|
||||||
/// Create an account the first time the proxy vouches for a name it has not seen.
|
/// Create an account the first time the proxy vouches for a name it has not seen.
|
||||||
pub auto_create_users: bool,
|
pub auto_create_users: bool,
|
||||||
/// Where Sign out sends someone the proxy signed in. Signing out of ipx alone cannot stick
|
/// Where Sign out sends someone the proxy signed in. Signing out of ipx alone cannot stick
|
||||||
@@ -96,6 +104,8 @@ impl Default for Web {
|
|||||||
token: String::new(),
|
token: String::new(),
|
||||||
trusted_header: String::new(),
|
trusted_header: String::new(),
|
||||||
trusted_proxies: vec!["127.0.0.1".into(), "::1".into()],
|
trusted_proxies: vec!["127.0.0.1".into(), "::1".into()],
|
||||||
|
access_team: String::new(),
|
||||||
|
access_aud: String::new(),
|
||||||
auto_create_users: true,
|
auto_create_users: true,
|
||||||
sign_out_url: String::new(),
|
sign_out_url: String::new(),
|
||||||
session_days: 30,
|
session_days: 30,
|
||||||
@@ -107,6 +117,12 @@ impl Web {
|
|||||||
pub fn binds_publicly(&self) -> bool {
|
pub fn binds_publicly(&self) -> bool {
|
||||||
!self.bind.starts_with("127.") && !self.bind.starts_with("localhost")
|
!self.bind.starts_with("127.") && !self.bind.starts_with("localhost")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Both halves of the Access check, or None while either is unset.
|
||||||
|
pub fn access(&self) -> Option<(&str, &str)> {
|
||||||
|
(!self.access_team.is_empty() && !self.access_aud.is_empty())
|
||||||
|
.then_some((self.access_team.as_str(), self.access_aud.as_str()))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
|
|||||||
77
src/feed.rs
77
src/feed.rs
@@ -698,10 +698,51 @@ fn title_text(s: Option<&str>) -> Option<String> {
|
|||||||
/// began halfway through a tag and the page showed the rest of the tag as text. The same item's
|
/// began halfway through a tag and the page showed the rest of the tag as text. The same item's
|
||||||
/// `description` was whole. With no description to fall back on, a damaged body beats none.
|
/// `description` was whole. With no description to fall back on, a damaged body beats none.
|
||||||
fn body(content: Option<&str>, description: Option<&str>) -> Option<String> {
|
fn body(content: Option<&str>, description: Option<&str>) -> Option<String> {
|
||||||
non_empty(content)
|
match non_empty(content).filter(|c| !starts_mid_tag(c)) {
|
||||||
.filter(|c| !starts_mid_tag(c))
|
Some(c) => Some(match subtitle(&c, description) {
|
||||||
.or_else(|| non_empty(description))
|
Some(s) => format!("<p><em>{}</em></p>{c}", quick_xml::escape::escape(s.as_str())),
|
||||||
.or_else(|| non_empty(content))
|
None => c,
|
||||||
|
}),
|
||||||
|
None => non_empty(description).or_else(|| non_empty(content)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A description that is a subtitle rather than a second copy of the notes: Substack puts the
|
||||||
|
/// post's subtitle there and leaves it out of `content:encoded`, so taking the body alone lost it.
|
||||||
|
/// Podcast feeds mostly repeat their notes in both, whole or cut short with an ellipsis, and a
|
||||||
|
/// description found in the body is not shown twice.
|
||||||
|
///
|
||||||
|
/// ponytail: short plain text not found in the body. A summary a podcast writes apart from its
|
||||||
|
/// notes passes too and shows above them, which reads fine; a real subtitle field would need an
|
||||||
|
/// `entries` column.
|
||||||
|
fn subtitle(body: &str, description: Option<&str>) -> Option<String> {
|
||||||
|
let d = title_text(description)?;
|
||||||
|
if d.contains('<') || d.chars().count() > 300 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
// Words alone: a tag taken out leaves "tape ," where the description has "tape,", and a cut
|
||||||
|
// description ends in "…" or "[...]".
|
||||||
|
let words = |s: &str| {
|
||||||
|
s.split(|c: char| !c.is_alphanumeric()).filter(|w| !w.is_empty()).collect::<Vec<_>>().join(" ").to_lowercase()
|
||||||
|
};
|
||||||
|
let want = words(&d);
|
||||||
|
let text = title_text(Some(&text_of(body))).unwrap_or_default();
|
||||||
|
(!want.is_empty() && !words(&text).contains(&want)).then_some(d)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// HTML with its tags taken out, each replaced by a space so words either side stay apart.
|
||||||
|
fn text_of(html: &str) -> String {
|
||||||
|
let mut out = String::with_capacity(html.len());
|
||||||
|
let mut in_tag = false;
|
||||||
|
for c in html.chars() {
|
||||||
|
match c {
|
||||||
|
'<' => in_tag = true,
|
||||||
|
'>' if in_tag => { in_tag = false; out.push(' '); }
|
||||||
|
_ if !in_tag => out.push(c),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Text that closes an attribute list (`">`) before any tag has opened is the tail of a tag whose
|
/// Text that closes an attribute list (`">`) before any tag has opened is the tail of a tag whose
|
||||||
@@ -841,12 +882,36 @@ mod tests {
|
|||||||
let cut = r#"*]:pointer-events-auto R6Vx5W_threadScrollVars" dir="auto" data-turn="assistant"> <p>What if</p>"#;
|
let cut = r#"*]:pointer-events-auto R6Vx5W_threadScrollVars" dir="auto" data-turn="assistant"> <p>What if</p>"#;
|
||||||
let whole = r#"<div class="[&:has([data-writing-block])>*]:pointer-events-auto"><p>What if</p></div>"#;
|
let whole = r#"<div class="[&:has([data-writing-block])>*]:pointer-events-auto"><p>What if</p></div>"#;
|
||||||
assert_eq!(body(Some(cut), Some(whole)).as_deref(), Some(whole));
|
assert_eq!(body(Some(cut), Some(whole)).as_deref(), Some(whole));
|
||||||
assert_eq!(body(Some("<p>Notes</p>"), Some("Summary")).as_deref(), Some("<p>Notes</p>"), "a whole body wins");
|
assert_eq!(body(Some("<p>Notes</p>"), Some("<p>Notes</p>")).as_deref(), Some("<p>Notes</p>"), "a whole body wins");
|
||||||
assert_eq!(body(Some("Plain notes, no tags."), Some("Summary")).as_deref(), Some("Plain notes, no tags."));
|
assert_eq!(body(Some("Plain notes, no tags."), Some("Plain notes, no tags.")).as_deref(), Some("Plain notes, no tags."));
|
||||||
assert_eq!(body(Some(cut), None).as_deref(), Some(cut), "a damaged body beats none");
|
assert_eq!(body(Some(cut), None).as_deref(), Some(cut), "a damaged body beats none");
|
||||||
assert_eq!(body(None, Some("Summary")).as_deref(), Some("Summary"));
|
assert_eq!(body(None, Some("Summary")).as_deref(), Some("Summary"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_subtitle_missing_from_the_body_is_kept_above_it() {
|
||||||
|
// Substack: the subtitle is the description, and content:encoded does not repeat it.
|
||||||
|
assert_eq!(
|
||||||
|
body(Some("<p>The post.</p>"), Some("Why the <b> tag & I fell out")).as_deref(),
|
||||||
|
Some("<p>The post.</p>"),
|
||||||
|
"a description with markup in it is notes, not a subtitle",
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
body(Some("<p>The post.</p>"), Some("Why Q&A threads go wrong")).as_deref(),
|
||||||
|
Some("<p><em>Why Q&A threads go wrong</em></p><p>The post.</p>"),
|
||||||
|
);
|
||||||
|
// A podcast repeating its notes, whole, cut short, or differently spaced: shown once.
|
||||||
|
let notes = "<p>This week we talk about <a href=\"x\">tape</a>, drums and a very long list.</p>";
|
||||||
|
for d in ["This week we talk about tape, drums and a very long list.",
|
||||||
|
"This week we talk about tape, drums…",
|
||||||
|
"This week we talk about\ntape [...]"] {
|
||||||
|
assert_eq!(body(Some(notes), Some(d)).as_deref(), Some(notes), "{d:?} is already in the body");
|
||||||
|
}
|
||||||
|
let long = "word ".repeat(80);
|
||||||
|
assert_eq!(body(Some("<p>The post.</p>"), Some(&long)).as_deref(), Some("<p>The post.</p>"),
|
||||||
|
"a long description is notes, not a subtitle");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn feed_level_explicit_overrides_entries() {
|
fn feed_level_explicit_overrides_entries() {
|
||||||
let xml = br#"<?xml version="1.0"?>
|
let xml = br#"<?xml version="1.0"?>
|
||||||
|
|||||||
18
src/main.rs
18
src/main.rs
@@ -1,3 +1,4 @@
|
|||||||
|
mod access;
|
||||||
mod auth;
|
mod auth;
|
||||||
mod config;
|
mod config;
|
||||||
mod db;
|
mod db;
|
||||||
@@ -550,11 +551,16 @@ async fn start_web(
|
|||||||
// The token is config.toml's, not the database's: it decides who gets in.
|
// The token is config.toml's, not the database's: it decides who gets in.
|
||||||
fresh.save_bootstrap(config_path)?;
|
fresh.save_bootstrap(config_path)?;
|
||||||
ctx.set_cfg(fresh.clone());
|
ctx.set_cfg(fresh.clone());
|
||||||
println!("web ui token generated. Open:\n http://{bind}/?token={}", fresh.web.token);
|
// The token signs in as the admin, and whatever reads this process's output (docker logs,
|
||||||
|
// for one) is wider than who reads config.toml. So say where it is, never what it is.
|
||||||
|
println!(
|
||||||
|
"web ui token generated and saved to {} as [web] token. Open http://{bind}/?token=<that token>",
|
||||||
|
config_path.display()
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
println!(
|
println!(
|
||||||
"web ui at http://{bind}/?token={}",
|
"web ui at http://{bind}/ (the sign-in token is [web] token in {})",
|
||||||
ctx.cfg().web.token
|
config_path.display()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -562,10 +568,16 @@ async fn start_web(
|
|||||||
tracing::warn!(bind, "web ui is reachable off this machine; the token is all that guards it");
|
tracing::warn!(bind, "web ui is reachable off this machine; the token is all that guards it");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let access = Arc::new(access::Keys::default());
|
||||||
|
if let Some((team, _)) = ctx.cfg().web.access() {
|
||||||
|
let (access, ctx, team) = (access.clone(), ctx.clone(), team.to_owned());
|
||||||
|
tokio::spawn(async move { access.prefetch(&ctx.client, &team).await });
|
||||||
|
}
|
||||||
let state = web::WebState {
|
let state = web::WebState {
|
||||||
ctx: ctx.clone(),
|
ctx: ctx.clone(),
|
||||||
cmds: cmds.clone(),
|
cmds: cmds.clone(),
|
||||||
events: events.clone(),
|
events: events.clone(),
|
||||||
|
access,
|
||||||
};
|
};
|
||||||
Ok(Some(tokio::spawn(async move {
|
Ok(Some(tokio::spawn(async move {
|
||||||
if let Err(e) = web::serve(state, &bind).await {
|
if let Err(e) = web::serve(state, &bind).await {
|
||||||
|
|||||||
39
src/web.rs
39
src/web.rs
@@ -29,6 +29,8 @@ pub struct WebState {
|
|||||||
pub ctx: Arc<Ctx>,
|
pub ctx: Arc<Ctx>,
|
||||||
pub cmds: mpsc::Sender<Command>,
|
pub cmds: mpsc::Sender<Command>,
|
||||||
pub events: broadcast::Sender<Event>,
|
pub events: broadcast::Sender<Event>,
|
||||||
|
/// Cloudflare Access's signing keys, fetched once and kept.
|
||||||
|
pub access: Arc<crate::access::Keys>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn router(state: WebState) -> Router {
|
pub fn router(state: WebState) -> Router {
|
||||||
@@ -99,7 +101,7 @@ async fn auth(State(state): State<WebState>, mut req: Request, next: Next) -> Re
|
|||||||
let token = cfg.web.token.clone();
|
let token = cfg.web.token.clone();
|
||||||
|
|
||||||
// 1. A header, but only from a hop we were told to believe.
|
// 1. A header, but only from a hop we were told to believe.
|
||||||
let vouched = vouched_name(&cfg, &req);
|
let vouched = vouched_name(&state, &cfg, peer(&req), req.headers()).await;
|
||||||
|
|
||||||
let mut set_cookie: Option<String> = None;
|
let mut set_cookie: Option<String> = None;
|
||||||
let mut user = None;
|
let mut user = None;
|
||||||
@@ -201,20 +203,31 @@ struct Proxied(bool);
|
|||||||
|
|
||||||
/// The name the proxy vouches for, when this request came from one of `trusted_proxies` and
|
/// The name the proxy vouches for, when this request came from one of `trusted_proxies` and
|
||||||
/// carries `trusted_header`. Anyone able to reach the port could otherwise send the header and
|
/// carries `trusted_header`. Anyone able to reach the port could otherwise send the header and
|
||||||
/// be whoever they liked.
|
/// be whoever they liked. With `access_team` and `access_aud` set, it also has to carry a
|
||||||
fn vouched_name(cfg: &crate::config::Config, req: &Request) -> Option<String> {
|
/// token Cloudflare Access signed, and the name is the one in the token.
|
||||||
let peer = req
|
///
|
||||||
.extensions()
|
/// Takes the request's parts rather than the request: a `&Request` held across the await makes
|
||||||
.get::<axum::extract::ConnectInfo<std::net::SocketAddr>>()
|
/// the future unsendable, as a body is not `Sync`.
|
||||||
.map(|c| c.0.ip().to_string())
|
async fn vouched_name(state: &WebState, cfg: &crate::config::Config, peer: String, headers: &axum::http::HeaderMap) -> Option<String> {
|
||||||
.unwrap_or_default();
|
|
||||||
if cfg.web.trusted_header.is_empty() || !cfg.web.trusted_proxies.iter().any(|p| p == &peer) {
|
if cfg.web.trusted_header.is_empty() || !cfg.web.trusted_proxies.iter().any(|p| p == &peer) {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
req.headers()
|
let header = |name: &str| headers.get(name).and_then(|v| v.to_str().ok());
|
||||||
.get(&cfg.web.trusted_header)
|
let Some((team, aud)) = cfg.web.access() else {
|
||||||
.and_then(|v| v.to_str().ok())
|
return header(&cfg.web.trusted_header).and_then(crate::auth::name_from_header);
|
||||||
.and_then(crate::auth::name_from_header)
|
};
|
||||||
|
// The plain header still has to be there, as it is what switches this path on for a
|
||||||
|
// request; who it names is the token's to say.
|
||||||
|
header(&cfg.web.trusted_header)?;
|
||||||
|
let token = header("Cf-Access-Jwt-Assertion")?;
|
||||||
|
state.access.verify(&state.ctx.client, team, aud, token).await
|
||||||
|
}
|
||||||
|
|
||||||
|
fn peer(req: &Request) -> String {
|
||||||
|
req.extensions()
|
||||||
|
.get::<axum::extract::ConnectInfo<std::net::SocketAddr>>()
|
||||||
|
.map(|c| c.0.ip().to_string())
|
||||||
|
.unwrap_or_default()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handlers take `User` to say they need one; the auth layer put it there, and nothing
|
/// Handlers take `User` to say they need one; the auth layer put it there, and nothing
|
||||||
@@ -474,7 +487,7 @@ async fn remove_user(
|
|||||||
/// The password form, except for someone the proxy vouches for: they are signed in already, and
|
/// The password form, except for someone the proxy vouches for: they are signed in already, and
|
||||||
/// the form only made it look as if they were not.
|
/// the form only made it look as if they were not.
|
||||||
async fn login_page(State(state): State<WebState>, req: Request) -> Response {
|
async fn login_page(State(state): State<WebState>, req: Request) -> Response {
|
||||||
if vouched_name(&state.ctx.cfg(), &req).is_some() {
|
if vouched_name(&state, &state.ctx.cfg(), peer(&req), req.headers()).await.is_some() {
|
||||||
return Redirect::to("/").into_response();
|
return Redirect::to("/").into_response();
|
||||||
}
|
}
|
||||||
([(header::CACHE_CONTROL, PAGE_CACHE)], Html(include_str!(concat!(env!("OUT_DIR"), "/login.html"))))
|
([(header::CACHE_CONTROL, PAGE_CACHE)], Html(include_str!(concat!(env!("OUT_DIR"), "/login.html"))))
|
||||||
|
|||||||
96
tests/contrast.js
Normal file
96
tests/contrast.js
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
// Every theme's palette, checked against WCAG AA for the pairs the page actually draws. The
|
||||||
|
// published palettes ipx borrows (Flat Remix, Paper, Adwaita...) fell short in places: an unread
|
||||||
|
// count at 2.6:1, tags at 3.2:1. Each was tuned by hand; this keeps a new or edited theme honest.
|
||||||
|
//
|
||||||
|
// node tests/contrast.js
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const css = fs.readFileSync(path.join(__dirname, '../web/app.css'), 'utf8');
|
||||||
|
const blocks = {};
|
||||||
|
for (const m of css.matchAll(/^(:root(?:\[[^\]]+\])*)\s*\{([^}]*)\}/gm)) {
|
||||||
|
const vars = {};
|
||||||
|
for (const v of m[2].matchAll(/--(\w+):\s*(#[0-9a-f]{6})\b/gi)) vars[v[1]] = v[2].toLowerCase();
|
||||||
|
if (Object.keys(vars).length) blocks[m[1]] = vars;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lum = h => {
|
||||||
|
const c = [1, 3, 5].map(i => parseInt(h.slice(i, i + 2), 16) / 255)
|
||||||
|
.map(c => c <= .03928 ? c / 12.92 : ((c + .055) / 1.055) ** 2.4);
|
||||||
|
return .2126 * c[0] + .7152 * c[1] + .0722 * c[2];
|
||||||
|
};
|
||||||
|
const ratio = (a, b) => { const x = lum(a), y = lum(b); return (Math.max(x, y) + .05) / (Math.min(x, y) + .05); };
|
||||||
|
|
||||||
|
// [colour, grounds it is drawn on, minimum]. 4.5 for text, 3 for an icon, dot or bar.
|
||||||
|
const RULES = [
|
||||||
|
['fg', ['bg', 'panel', 'panel2', 'raise'], 4.5],
|
||||||
|
['dim', ['bg', 'panel', 'panel2'], 4.5], // metadata, labels
|
||||||
|
['faint', ['bg', 'panel', 'panel2'], 4.5], // hints, column headings, the status bar
|
||||||
|
['accent', ['bg', 'panel'], 4.5], // links, in the list and the reader
|
||||||
|
['ink', ['accent'], 4.5], // a primary button's label
|
||||||
|
['ink', ['accent2'], 4.5], // the unread count on its badge
|
||||||
|
['accent2', ['bg', 'panel', 'raise'], 3], // the unread dot, the EQ bars, download bars
|
||||||
|
['bad', ['bg', 'panel'], 4.5], // error text, the Error tag, a failed toast
|
||||||
|
['warn', ['bg', 'panel'], 4.5], // the Gone tag, log warnings
|
||||||
|
['good', ['bg', 'panel', 'panel2'], 3], // the downloaded and subscribed icons
|
||||||
|
];
|
||||||
|
|
||||||
|
const base = blocks[':root'];
|
||||||
|
const themes = {};
|
||||||
|
for (const sel of Object.keys(blocks)) {
|
||||||
|
const t = sel.match(/data-theme="(\w+)"/);
|
||||||
|
if (!t) continue;
|
||||||
|
const light = /data-mode="light"/.test(sel);
|
||||||
|
// A theme's dark half is :root under its own block; its light half adds the light block.
|
||||||
|
const name = `${t[1]}${light ? ' light' : ''}`;
|
||||||
|
themes[name] = light
|
||||||
|
? { ...base, ...blocks[`:root[data-theme="${t[1]}"]`], ...blocks[sel] }
|
||||||
|
: { ...base, ...blocks[sel] };
|
||||||
|
}
|
||||||
|
themes['modern'] = base;
|
||||||
|
|
||||||
|
let bad = 0;
|
||||||
|
for (const [name, p] of Object.entries(themes)) {
|
||||||
|
for (const [fg, grounds, min] of RULES) for (const g of grounds) {
|
||||||
|
const r = ratio(p[fg], p[g]);
|
||||||
|
if (r < min) { bad++; console.log(`FAIL ${name}: --${fg} on --${g} is ${r.toFixed(2)}:1, needs ${min}`); }
|
||||||
|
}
|
||||||
|
// A border the same colour as the ground it is drawn on does not show (Nordic, once).
|
||||||
|
if (p.line === p.panel2) { bad++; console.log(`FAIL ${name}: --line is --panel2, so borders on it vanish`); }
|
||||||
|
}
|
||||||
|
// Glass draws its text on a coloured wash, or on a panel that lets the wash through, so its hex
|
||||||
|
// grounds are not what the text lands on. Sample the wash the way the browser composites it, on a
|
||||||
|
// laptop, a phone and a tablet, and hold the text to AA wherever it is darkest or lightest.
|
||||||
|
const washes = [...css.matchAll(/radial-gradient\((\d+)% (\d+)% at (\d+)% (\d+)%,var\(--wash(\d)\)/g)]
|
||||||
|
.map(m => ({ rx: +m[1], ry: +m[2], cx: +m[3], cy: +m[4], n: m[5] }));
|
||||||
|
const rgba = (sel, n) => {
|
||||||
|
const m = css.slice(css.indexOf(sel + ' {')).match(new RegExp(`--wash${n}:rgba\\((\\d+),(\\d+),(\\d+),([\\d.]+)\\)`));
|
||||||
|
return [[+m[1], +m[2], +m[3]], +m[4]];
|
||||||
|
};
|
||||||
|
const rgb = h => [1, 3, 5].map(i => parseInt(h.slice(i, i + 2), 16));
|
||||||
|
const hex = c => '#' + c.map(v => Math.round(v).toString(16).padStart(2, '0')).join('');
|
||||||
|
const over = (c, a, g) => g.map((x, i) => c[i] * a + x * (1 - a));
|
||||||
|
for (const [name, sel] of [['glass', ':root[data-theme="glass"]'], ['glass light', ':root[data-theme="glass"][data-mode="light"]']]) {
|
||||||
|
const p = themes[name];
|
||||||
|
const tint = washes.map(w => [w, rgba(sel, w.n)]).reverse(); // the first listed is drawn on top
|
||||||
|
const worst = {};
|
||||||
|
for (const [W, H] of [[1440, 900], [390, 844], [1024, 1366]])
|
||||||
|
for (let x = 0; x <= W; x += W / 40) for (let y = 0; y <= H; y += H / 40) {
|
||||||
|
let g = rgb(p.bg);
|
||||||
|
for (const [w, [c, a]] of tint) {
|
||||||
|
const d = Math.hypot((x - w.cx * W / 100) / (w.rx * W / 100), (y - w.cy * H / 100) / (w.ry * H / 100));
|
||||||
|
g = over(c, a * Math.max(0, 1 - d), g);
|
||||||
|
}
|
||||||
|
// The list sits on the bare wash; the sidebar, detail pane and dialogs on 70% panel over it.
|
||||||
|
for (const [gn, gc] of [['the wash', g], ['a panel', over(rgb(p.panel), .7, g)]])
|
||||||
|
for (const fg of ['fg', 'dim', 'faint', 'accent', 'bad', 'warn']) {
|
||||||
|
const r = ratio(p[fg], hex(gc)), k = `--${fg} on ${gn}`;
|
||||||
|
if (!(k in worst) || r < worst[k]) worst[k] = r;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!washes.length) { bad++; console.log('FAIL glass: no wash gradients found in app.css'); }
|
||||||
|
for (const [k, r] of Object.entries(worst))
|
||||||
|
if (r < 4.5) { bad++; console.log(`FAIL ${name}: ${k} is ${r.toFixed(2)}:1 at worst, needs 4.5`); }
|
||||||
|
}
|
||||||
|
if (bad) process.exit(1);
|
||||||
|
console.log(`OK: ${Object.keys(themes).length} palettes clear AA for every pair the page draws`);
|
||||||
BIN
tests/data/access-forger.der
Normal file
BIN
tests/data/access-forger.der
Normal file
Binary file not shown.
BIN
tests/data/access-test.der
Normal file
BIN
tests/data/access-test.der
Normal file
Binary file not shown.
12
tests/data/access-test.jwks.json
Normal file
12
tests/data/access-test.jwks.json
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"keys": [
|
||||||
|
{
|
||||||
|
"kid": "k1",
|
||||||
|
"kty": "RSA",
|
||||||
|
"alg": "RS256",
|
||||||
|
"use": "sig",
|
||||||
|
"e": "AQAB",
|
||||||
|
"n": "1T_jY4dGnU5YJonLMXdTyqFdV2J-67t5NTmTP1mf6kEYw_lW1xWB7306w8XOiplWD9cEDviKh6vQbmTTXL6-z8WnG-9YeRsPOOv0vb8txiuzJZ10ZQDBpbDdfidcESryl6ts7-ApsFz27B060wmHTwL4pywQw4wwmrubkiRwvpidzBpmDlkGZHdy3XV2TTfzQwwTtTuCR6Fd6D8lfK0XL6J5UC-RTH8_v9XEjF7DnI_bflB0olEwAqJ0-3E4xOj9okLOO5sfwE2SZk4yEMhFV4xqjtv8EN0KMT6BGIGs_VPDrSVtt23sEMsDOmeO6Pf9C6bkXy6faREpsX4einQykw"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
131
tests/dom-stub.js
Normal file
131
tests/dom-stub.js
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
// The stub DOM the page's script is loaded against, shared by page-smoke.js and
|
||||||
|
// native-bridge.js. It is deliberately thin: enough for every handler the script wires at load
|
||||||
|
// to find what it reaches for, and no more.
|
||||||
|
//
|
||||||
|
// `media` swaps the bare `#audio` proxy for something with the parts of HTMLMediaElement that
|
||||||
|
// matter -- a prototype carrying the real accessors, and events that actually dispatch -- because
|
||||||
|
// native.ts replaces that surface on the element and a proxy that answers everything would prove
|
||||||
|
// nothing about whether it worked.
|
||||||
|
const vm = require('vm');
|
||||||
|
|
||||||
|
function makeContext(html, script, { media = false } = {}) {
|
||||||
|
// Ids in the page, and in the markup the script builds for its dialogs.
|
||||||
|
const ids = new Set([...(html + script).matchAll(/\bid=(?:"([^"]+)"|([^\s>"']+))/g)].map(m => m[1] || m[2]));
|
||||||
|
const missing = [];
|
||||||
|
|
||||||
|
const el = (name) => new Proxy({ style: { setProperty(){}, getPropertyValue(){ return ''; } }, dataset: {}, classList: { add(){}, remove(){}, toggle(){}, contains(){ return false; } },
|
||||||
|
value: '', textContent: '', innerHTML: '', hidden: false, children: [], firstElementChild: null,
|
||||||
|
appendChild(){}, removeChild(){}, remove(){}, insertAdjacentHTML(){}, addEventListener(){},
|
||||||
|
setAttribute(){}, getAttribute(){ return null; }, select(){}, setSelectionRange(){}, focus(){},
|
||||||
|
replaceWith(){}, querySelector(){ return el('nested'); }, querySelectorAll(){ return []; },
|
||||||
|
play(){ return Promise.resolve(); }, pause(){}, closest(){ return null; } },
|
||||||
|
{ get: (t, k) => k in t ? t[k] : undefined, set: (t, k, v) => (t[k] = v, true) });
|
||||||
|
|
||||||
|
const body = el('body');
|
||||||
|
const audio = media ? makeMediaElement() : null;
|
||||||
|
if (media) {
|
||||||
|
// native.ts reads has-video off the body to decide whether the host takes the file, so this
|
||||||
|
// one has to be a real set rather than something that always says no.
|
||||||
|
const classes = new Set();
|
||||||
|
body.classList = {
|
||||||
|
add: c => classes.add(c), remove: c => classes.delete(c),
|
||||||
|
toggle: (c, on) => (on === undefined ? (classes.has(c) ? classes.delete(c) : classes.add(c)) : on ? classes.add(c) : classes.delete(c)),
|
||||||
|
contains: c => classes.has(c),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const document = {
|
||||||
|
querySelector(sel) {
|
||||||
|
if (sel === '#audio' && audio) return audio;
|
||||||
|
if (sel.startsWith('#') && !ids.has(sel.slice(1))) { missing.push(sel); return null; }
|
||||||
|
return el(sel);
|
||||||
|
},
|
||||||
|
querySelectorAll: () => [],
|
||||||
|
createElement: () => el('created'),
|
||||||
|
addEventListener(){}, body,
|
||||||
|
documentElement: { dataset: {} },
|
||||||
|
};
|
||||||
|
|
||||||
|
const ctx = {
|
||||||
|
document, console,
|
||||||
|
window: { isSecureContext: false, addEventListener(){} },
|
||||||
|
localStorage: { getItem: () => null, setItem(){}, removeItem(){} },
|
||||||
|
navigator: { clipboard: undefined, sendBeacon(){}, mediaSession: undefined },
|
||||||
|
fetch: (url) => Promise.resolve({
|
||||||
|
ok: true, status: 200, text: () => Promise.resolve(''),
|
||||||
|
json: () => Promise.resolve(
|
||||||
|
String(url).includes('/api/settings')
|
||||||
|
? { schedule: 'every 60m', every_mins: 60, download_dir: '/tmp', max_total_gb: 0, max_age_days: 0 }
|
||||||
|
: String(url).includes('/api/users')
|
||||||
|
? [{ id: 1, name: 'admin', admin: true, password: true }, { id: 2, name: 'sam', admin: false, password: false }]
|
||||||
|
: /\/api\/(popular|directory)/.test(String(url))
|
||||||
|
? [{ id: 'f', title: 'A Feed', image: null, subscribers: 2, subscribed: true },
|
||||||
|
{ id: 'g', title: null, image: null, subscribers: 1, subscribed: false }]
|
||||||
|
: /entries/.test(String(url)) ? { total: 0, entries: [] } : []),
|
||||||
|
}),
|
||||||
|
EventSource: function () { this.close = () => {}; },
|
||||||
|
MediaMetadata: function () {},
|
||||||
|
Blob: function () {},
|
||||||
|
setTimeout, clearTimeout, setInterval, clearInterval,
|
||||||
|
confirm: () => false, prompt: () => null, alert(){},
|
||||||
|
Date, Math, JSON, Object, Array, String, Number, Promise, Error, FormData: function(){},
|
||||||
|
URLSearchParams, encodeURIComponent, decodeURIComponent, parseInt, parseFloat, isNaN,
|
||||||
|
};
|
||||||
|
if (media) {
|
||||||
|
ctx.HTMLMediaElement = MediaElement;
|
||||||
|
ctx.Event = Event;
|
||||||
|
ctx.isFinite = isFinite;
|
||||||
|
ctx.CSS = { escape: s => String(s) };
|
||||||
|
}
|
||||||
|
ctx.globalThis = ctx;
|
||||||
|
ctx.window.location = { href: '', hash: '' };
|
||||||
|
ctx.location = ctx.window.location;
|
||||||
|
|
||||||
|
return { ctx, missing, audio, body, ids };
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- just enough HTMLMediaElement for the shim to be worth testing ---- */
|
||||||
|
|
||||||
|
function Event(type) { this.type = type; }
|
||||||
|
|
||||||
|
function MediaElement() {
|
||||||
|
this._src = ''; this._t = 0; this._dur = NaN; this._paused = true;
|
||||||
|
this._ready = 0; this._vol = 1; this._rate = 1;
|
||||||
|
this._listeners = {};
|
||||||
|
this.dataset = {}; this.classList = { add(){}, remove(){}, toggle(){}, contains(){ return false; } };
|
||||||
|
// Every call that reached the real element, so a test can say the host took over rather than
|
||||||
|
// the element quietly playing as well.
|
||||||
|
this.calls = [];
|
||||||
|
}
|
||||||
|
MediaElement.prototype.play = function(){ this.calls.push('play'); this._paused = false; return Promise.resolve(); };
|
||||||
|
MediaElement.prototype.pause = function(){ this.calls.push('pause'); this._paused = true; };
|
||||||
|
MediaElement.prototype.load = function(){ this.calls.push('load'); };
|
||||||
|
MediaElement.prototype.addEventListener = function(name, fn, opts){
|
||||||
|
(this._listeners[name] || (this._listeners[name] = [])).push({ fn, once: !!(opts && opts.once) });
|
||||||
|
};
|
||||||
|
MediaElement.prototype.dispatchEvent = function(ev){
|
||||||
|
for (const l of (this._listeners[ev.type] || []).slice()) {
|
||||||
|
if (l.once) this._listeners[ev.type] = this._listeners[ev.type].filter(x => x !== l);
|
||||||
|
l.fn(ev);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
MediaElement.prototype.removeAttribute = function(name){ this.calls.push('removeAttribute:' + name); if (name === 'src') this._src = ''; };
|
||||||
|
MediaElement.prototype.setAttribute = function(){};
|
||||||
|
MediaElement.prototype.getAttribute = function(){ return null; };
|
||||||
|
const accessor = (k, field, log) => Object.defineProperty(MediaElement.prototype, k, {
|
||||||
|
configurable: true,
|
||||||
|
get(){ return this[field]; },
|
||||||
|
set(v){ if (log) this.calls.push(k + ':' + v); this[field] = v; },
|
||||||
|
});
|
||||||
|
accessor('src', '_src', true);
|
||||||
|
accessor('currentTime', '_t', true);
|
||||||
|
accessor('volume', '_vol');
|
||||||
|
accessor('playbackRate', '_rate');
|
||||||
|
Object.defineProperty(MediaElement.prototype, 'duration', { configurable: true, get(){ return this._dur; } });
|
||||||
|
Object.defineProperty(MediaElement.prototype, 'paused', { configurable: true, get(){ return this._paused; } });
|
||||||
|
Object.defineProperty(MediaElement.prototype, 'readyState', { configurable: true, get(){ return this._ready; } });
|
||||||
|
|
||||||
|
function makeMediaElement(){ return new MediaElement(); }
|
||||||
|
|
||||||
|
module.exports = { makeContext, vm };
|
||||||
117
tests/native-bridge.js
Normal file
117
tests/native-bridge.js
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
// The page inside a native shell: web/src/native.ts should take playback off the element and
|
||||||
|
// hand it to the host, while everything in player.ts carries on talking to the element.
|
||||||
|
//
|
||||||
|
// This is the check that the shim and player.ts still agree. The surface native.ts replaces --
|
||||||
|
// play, pause, src, currentTime, duration, paused, readyState, the events -- is player.ts's
|
||||||
|
// alone, so a change there that steps outside it would otherwise break the app in a car, on a
|
||||||
|
// road, with nothing to look at.
|
||||||
|
//
|
||||||
|
// node tests/native-bridge.js
|
||||||
|
const { makeContext, vm } = require('./dom-stub.js');
|
||||||
|
const { buildPage } = require('../web/build.mjs');
|
||||||
|
|
||||||
|
const { html, js: script } = buildPage('index.html');
|
||||||
|
|
||||||
|
let failed = 0;
|
||||||
|
const ok = (cond, what) => { if (!cond) { console.error('FAIL: ' + what); failed++; } };
|
||||||
|
|
||||||
|
/* ---- a browser: nothing installs ---- */
|
||||||
|
{
|
||||||
|
const { ctx } = makeContext(html, script, { media: true });
|
||||||
|
vm.createContext(ctx);
|
||||||
|
vm.runInContext(script, ctx, { filename: 'browser', timeout: 5000 });
|
||||||
|
ok(ctx.window.ipxNative === undefined, 'the bridge installed in a plain browser');
|
||||||
|
const audio = ctx.document.querySelector('#audio');
|
||||||
|
audio.src = '/media/1';
|
||||||
|
ok(audio.calls.includes('src:/media/1'), 'a browser did not set the real src');
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- inside the shell ---- */
|
||||||
|
const posted = [];
|
||||||
|
const { ctx, audio, body } = makeContext(html, script, { media: true });
|
||||||
|
ctx.window.webkit = { messageHandlers: { ipx: { postMessage: m => posted.push(m) } } };
|
||||||
|
vm.createContext(ctx);
|
||||||
|
vm.runInContext(script, ctx, { filename: 'shell', timeout: 5000 });
|
||||||
|
|
||||||
|
const last = t => [...posted].reverse().find(m => m.t === t);
|
||||||
|
const since = () => posted.splice(0, posted.length);
|
||||||
|
|
||||||
|
ok(ctx.window.ipxNative && ctx.window.ipxNative.version === 1, 'window.ipxNative is not there for the host to call');
|
||||||
|
ok(last('ready'), 'the host was never told the bridge is in');
|
||||||
|
since();
|
||||||
|
|
||||||
|
// What play() does: player.ts fills in `player`, marks the body, sets the src, then plays.
|
||||||
|
const entry = { guid: 'g1', feed_id: 'f', title: 'Episode One', image: null, position: 0, duration: 1800, read: false,
|
||||||
|
enclosures: [{ id: 42, mime: 'audio/mpeg', path: '/downloads/f/ep1.mp3', url: 'https://x/ep1.mp3' }] };
|
||||||
|
vm.runInContext('S.feeds=[{id:"f",title:"A Feed",image:"/art.jpg"}]', ctx);
|
||||||
|
vm.runInContext('player.guid="g1";player.feed="f";player.enc=42;player.entry=E', Object.assign(ctx, { E: entry }));
|
||||||
|
|
||||||
|
body.classList.toggle('has-video', false);
|
||||||
|
audio.calls.length = 0;
|
||||||
|
audio.src = '/media/42';
|
||||||
|
|
||||||
|
const load = last('load');
|
||||||
|
ok(load, 'setting the src told the host nothing');
|
||||||
|
if (load) {
|
||||||
|
ok(load.url === '/media/42' && load.enc === 42, 'the host was not told which file');
|
||||||
|
ok(load.feedId === 'f' && load.guid === 'g1', 'the host cannot save a position without the feed and guid');
|
||||||
|
ok(load.title === 'Episode One' && load.feedTitle === 'A Feed', 'now-playing has nothing to show');
|
||||||
|
ok(load.artwork === '/art.jpg', "the feed's art did not stand in for an episode without its own");
|
||||||
|
}
|
||||||
|
ok(!audio.calls.some(c => c.startsWith('src:')), 'the element loaded the file as well as the host');
|
||||||
|
ok(audio.calls.includes('load'), 'the element was not made to let go of what it held');
|
||||||
|
|
||||||
|
// player.ts sets currentTime=0 straight after the src.
|
||||||
|
since();
|
||||||
|
audio.currentTime = 0;
|
||||||
|
ok(last('seek') && last('seek').to === 0, 'a seek did not reach the host');
|
||||||
|
|
||||||
|
since();
|
||||||
|
audio.play();
|
||||||
|
ok(last('play'), 'play did not reach the host');
|
||||||
|
ok(!audio.calls.includes('play'), 'the element played too -- two engines on one file');
|
||||||
|
|
||||||
|
// The host answers, and the page must move as it would have on its own.
|
||||||
|
let played = 0, timed = 0;
|
||||||
|
audio.addEventListener('play', () => played++);
|
||||||
|
audio.addEventListener('timeupdate', () => timed++);
|
||||||
|
ctx.window.ipxNative.on({ t: 'state', playing: true });
|
||||||
|
ok(played === 1, 'the page never saw the host start playing');
|
||||||
|
ok(audio.paused === false, 'audio.paused still says paused while the host plays');
|
||||||
|
|
||||||
|
ctx.window.ipxNative.on({ t: 'meta', dur: 1800 });
|
||||||
|
ok(audio.duration === 1800, 'the duration the host measured did not reach the page');
|
||||||
|
ok(audio.readyState > 0, 'readyState stayed 0, which is what stops a position being saved');
|
||||||
|
|
||||||
|
ctx.window.ipxNative.on({ t: 'time', cur: 30 });
|
||||||
|
ok(audio.currentTime === 30, "the host's clock did not reach the page");
|
||||||
|
ok(timed > 0, 'no timeupdate, so the player bar would sit at zero');
|
||||||
|
|
||||||
|
// The 15-second key: a read and a write through the shim.
|
||||||
|
since();
|
||||||
|
audio.currentTime -= 15;
|
||||||
|
ok(last('seek') && last('seek').to === 15, 'back 15 seconds did not land at 15');
|
||||||
|
|
||||||
|
// Position saving is the host's: a frozen WebView must not write a time from minutes ago.
|
||||||
|
since();
|
||||||
|
const saved = ctx.navigator.sendBeacon('/api/entries/f/g1/position', {});
|
||||||
|
ok(saved === true, 'sendBeacon reported a failure the page would treat as unsaved');
|
||||||
|
ok(last('position') && /\/position$/.test(last('position').url), 'the position write did not become a request to the host');
|
||||||
|
|
||||||
|
// Closing the player has to stop the host, not just blank the element.
|
||||||
|
since();
|
||||||
|
audio.removeAttribute('src');
|
||||||
|
ok(last('stop'), 'closing the player left the host playing');
|
||||||
|
|
||||||
|
// Video stays on the element: CarPlay is audio-only, and a native video layer under a WebView
|
||||||
|
// buys nothing.
|
||||||
|
since();
|
||||||
|
audio.calls.length = 0;
|
||||||
|
body.classList.toggle('has-video', true);
|
||||||
|
audio.src = '/media/99';
|
||||||
|
ok(!last('load'), 'a video was handed to the host');
|
||||||
|
ok(audio.calls.includes('src:/media/99'), 'a video did not play on the element');
|
||||||
|
|
||||||
|
if (failed) { console.error(`\n${failed} failed`); process.exit(1); }
|
||||||
|
console.log('OK: native-bridge: the host takes playback and the page follows it');
|
||||||
|
process.exit(0);
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
// and every server-side test passed too, because the server was fine.
|
// and every server-side test passed too, because the server was fine.
|
||||||
//
|
//
|
||||||
// node tests/page-smoke.js
|
// node tests/page-smoke.js
|
||||||
const vm = require('vm');
|
const { makeContext, vm } = require('./dom-stub.js');
|
||||||
const PAGE = process.argv[2] || 'index.html';
|
const PAGE = process.argv[2] || 'index.html';
|
||||||
|
|
||||||
const { buildPage } = require('../web/build.mjs');
|
const { buildPage } = require('../web/build.mjs');
|
||||||
@@ -19,57 +19,7 @@ if (!/<link rel=stylesheet href="?\/app\.css\?v=[0-9a-f]{12}"?>/.test(html) && P
|
|||||||
if (!new RegExp(`<script src="?/${file.replace('.', '\\.')}\\?v=[0-9a-f]{12}"?>`).test(html)) {
|
if (!new RegExp(`<script src="?/${file.replace('.', '\\.')}\\?v=[0-9a-f]{12}"?>`).test(html)) {
|
||||||
console.error(`FAIL: the page does not load /${file}?v=<hash>`); process.exit(1);
|
console.error(`FAIL: the page does not load /${file}?v=<hash>`); process.exit(1);
|
||||||
}
|
}
|
||||||
// Ids in the page, and in the markup the script builds for its dialogs.
|
const { ctx, missing } = makeContext(html, script);
|
||||||
const ids = new Set([...(html + script).matchAll(/\bid=(?:"([^"]+)"|([^\s>"']+))/g)].map(m => m[1] || m[2]));
|
|
||||||
|
|
||||||
const missing = [];
|
|
||||||
const el = (name) => new Proxy({ style: { setProperty(){}, getPropertyValue(){ return ''; } }, dataset: {}, classList: { add(){}, remove(){}, toggle(){}, contains(){ return false; } },
|
|
||||||
value: '', textContent: '', innerHTML: '', hidden: false, children: [], firstElementChild: null,
|
|
||||||
appendChild(){}, removeChild(){}, remove(){}, insertAdjacentHTML(){}, addEventListener(){},
|
|
||||||
setAttribute(){}, getAttribute(){ return null; }, select(){}, setSelectionRange(){}, focus(){},
|
|
||||||
replaceWith(){}, querySelector(){ return el('nested'); }, querySelectorAll(){ return []; },
|
|
||||||
play(){ return Promise.resolve(); }, pause(){}, closest(){ return null; } },
|
|
||||||
{ get: (t, k) => k in t ? t[k] : undefined, set: (t, k, v) => (t[k] = v, true) });
|
|
||||||
|
|
||||||
const document = {
|
|
||||||
querySelector(sel) {
|
|
||||||
if (sel.startsWith('#') && !ids.has(sel.slice(1))) { missing.push(sel); return null; }
|
|
||||||
return el(sel);
|
|
||||||
},
|
|
||||||
querySelectorAll: () => [],
|
|
||||||
createElement: () => el('created'),
|
|
||||||
addEventListener(){}, body: el('body'),
|
|
||||||
documentElement: { dataset: {} },
|
|
||||||
};
|
|
||||||
|
|
||||||
const ctx = {
|
|
||||||
document, console,
|
|
||||||
window: { isSecureContext: false, addEventListener(){} },
|
|
||||||
localStorage: { getItem: () => null, setItem(){}, removeItem(){} },
|
|
||||||
navigator: { clipboard: undefined, sendBeacon(){}, mediaSession: undefined },
|
|
||||||
fetch: (url) => Promise.resolve({
|
|
||||||
ok: true, status: 200, text: () => Promise.resolve(''),
|
|
||||||
json: () => Promise.resolve(
|
|
||||||
String(url).includes('/api/settings')
|
|
||||||
? { schedule: 'every 60m', every_mins: 60, download_dir: '/tmp', max_total_gb: 0, max_age_days: 0 }
|
|
||||||
: String(url).includes('/api/users')
|
|
||||||
? [{ id: 1, name: 'admin', admin: true, password: true }, { id: 2, name: 'sam', admin: false, password: false }]
|
|
||||||
: /\/api\/(popular|directory)/.test(String(url))
|
|
||||||
? [{ id: 'f', title: 'A Feed', image: null, subscribers: 2, subscribed: true },
|
|
||||||
{ id: 'g', title: null, image: null, subscribers: 1, subscribed: false }]
|
|
||||||
: /entries/.test(String(url)) ? { total: 0, entries: [] } : []),
|
|
||||||
}),
|
|
||||||
EventSource: function () { this.close = () => {}; },
|
|
||||||
MediaMetadata: function () {},
|
|
||||||
Blob: function () {},
|
|
||||||
setTimeout, clearTimeout, setInterval, clearInterval,
|
|
||||||
confirm: () => false, prompt: () => null, alert(){},
|
|
||||||
Date, Math, JSON, Object, Array, String, Number, Promise, Error, FormData: function(){},
|
|
||||||
URLSearchParams, encodeURIComponent, decodeURIComponent, parseInt, parseFloat, isNaN,
|
|
||||||
};
|
|
||||||
ctx.globalThis = ctx;
|
|
||||||
ctx.window.location = { href: '', hash: '' };
|
|
||||||
ctx.location = ctx.window.location;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
vm.createContext(ctx);
|
vm.createContext(ctx);
|
||||||
|
|||||||
303
web/app.css
303
web/app.css
@@ -12,7 +12,7 @@
|
|||||||
--line:#2c3849;
|
--line:#2c3849;
|
||||||
--fg:#f5f5f5; /* #F5F5F5 device highlight */
|
--fg:#f5f5f5; /* #F5F5F5 device highlight */
|
||||||
--dim:#95a0b1; /* #95A0B1 straight from the icon's blue-grey */
|
--dim:#95a0b1; /* #95A0B1 straight from the icon's blue-grey */
|
||||||
--faint:#7a8799; /* lifted from the icon ramp until it clears AA at small sizes */
|
--faint:#7e8b9c; /* lifted from the icon ramp until it clears AA at small sizes */
|
||||||
--accent:#92b2e6; /* #92B2E6 the screen blue */
|
--accent:#92b2e6; /* #92B2E6 the screen blue */
|
||||||
--accent2:#f49e2c; /* #F49E2C the EQ bars */
|
--accent2:#f49e2c; /* #F49E2C the EQ bars */
|
||||||
--ink:#0e131b; /* text on an accent fill */
|
--ink:#0e131b; /* text on an accent fill */
|
||||||
@@ -239,6 +239,151 @@
|
|||||||
--bad:#9c3f49;
|
--bad:#9c3f49;
|
||||||
--shadow:0 8px 28px rgba(46,52,64,.15);
|
--shadow:0 8px 28px rgba(46,52,64,.15);
|
||||||
}
|
}
|
||||||
|
/* Catppuccin: Mocha for the dark half, Latte for the light, from catppuccin.com/palette. Base,
|
||||||
|
mantle and the surfaces for the grounds, text and subtext for type, mauve (its default accent)
|
||||||
|
and peach for the rest. */
|
||||||
|
:root[data-theme="catppuccin"] {
|
||||||
|
--bg:#1e1e2e; /* base */
|
||||||
|
--panel:#181825; /* mantle */
|
||||||
|
--panel2:#313244; /* surface0 */
|
||||||
|
--raise:#45475a; /* surface1 */
|
||||||
|
--line:#45475a;
|
||||||
|
--fg:#cdd6f4; /* text */
|
||||||
|
--dim:#bac2de; /* subtext1 */
|
||||||
|
--faint:#a6adc8; /* subtext0 */
|
||||||
|
--accent:#cba6f7; /* mauve */
|
||||||
|
--accent2:#fab387; /* peach */
|
||||||
|
--ink:#11111b; /* crust */
|
||||||
|
--good:#a6e3a1;
|
||||||
|
--warn:#f9e2af;
|
||||||
|
--bad:#f38ba8;
|
||||||
|
--shadow:0 8px 28px rgba(0,0,0,.45);
|
||||||
|
}
|
||||||
|
:root[data-theme="catppuccin"][data-mode="light"] {
|
||||||
|
--bg:#eff1f5; /* base */
|
||||||
|
--panel:#e6e9ef; /* mantle */
|
||||||
|
--panel2:#dce0e8; /* crust */
|
||||||
|
--raise:#ccd0da; /* surface0 */
|
||||||
|
--line:#bcc0cc; /* surface1 */
|
||||||
|
--fg:#4c4f69; /* text */
|
||||||
|
--dim:#5c5f77; /* subtext1 */
|
||||||
|
--faint:#5f6276; /* subtext0 */
|
||||||
|
--accent:#8638ec; /* mauve */
|
||||||
|
--accent2:#c74e09; /* peach */
|
||||||
|
--ink:#ffffff;
|
||||||
|
--good:#3a9127;
|
||||||
|
--warn:#925d13;
|
||||||
|
--bad:#d00f39;
|
||||||
|
--shadow:0 8px 28px rgba(76,79,105,.15);
|
||||||
|
}
|
||||||
|
/* Gruvbox, from its README's palette: the bg and fg ramps, with its faded colours for the light
|
||||||
|
half as gruvbox itself does. Blue for the accent, orange for what is new. */
|
||||||
|
:root[data-theme="gruvbox"] {
|
||||||
|
--bg:#282828; /* bg0 */
|
||||||
|
--panel:#1d2021; /* bg0_h */
|
||||||
|
--panel2:#32302f; /* bg0_s */
|
||||||
|
--raise:#3c3836; /* bg1 */
|
||||||
|
--line:#504945; /* bg2 */
|
||||||
|
--fg:#ebdbb2; /* fg */
|
||||||
|
--dim:#d5c4a1; /* fg2 */
|
||||||
|
--faint:#a89984; /* fg4 */
|
||||||
|
--accent:#83a598; /* blue */
|
||||||
|
--accent2:#fe8019; /* orange */
|
||||||
|
--ink:#1d2021;
|
||||||
|
--good:#b8bb26;
|
||||||
|
--warn:#fabd2f;
|
||||||
|
--bad:#fb533f;
|
||||||
|
--shadow:0 8px 28px rgba(0,0,0,.45);
|
||||||
|
}
|
||||||
|
:root[data-theme="gruvbox"][data-mode="light"] {
|
||||||
|
--bg:#fbf1c7; /* bg0 */
|
||||||
|
--panel:#f2e5bc; /* bg0_s */
|
||||||
|
--panel2:#ebdbb2; /* bg1 */
|
||||||
|
--raise:#d5c4a1; /* bg2 */
|
||||||
|
--line:#d5c4a1;
|
||||||
|
--fg:#3c3836; /* fg */
|
||||||
|
--dim:#504945; /* fg2 */
|
||||||
|
--faint:#665c54; /* fg3 */
|
||||||
|
--accent:#076678; /* faded blue */
|
||||||
|
--accent2:#af3a03; /* faded orange */
|
||||||
|
--ink:#fbf1c7;
|
||||||
|
--good:#79740e;
|
||||||
|
--warn:#8d5c10;
|
||||||
|
--bad:#9d0006;
|
||||||
|
--shadow:0 8px 28px rgba(60,56,54,.15);
|
||||||
|
}
|
||||||
|
/* Solarized, from ethanschoonover.com/solarized: base03 and base3 for the grounds, the base tones
|
||||||
|
for type. It has two grounds per half, so panel2 and raise are steps between its own. */
|
||||||
|
:root[data-theme="solarized"] {
|
||||||
|
--bg:#002b36; /* base03 */
|
||||||
|
--panel:#073642; /* base02 */
|
||||||
|
--panel2:#0b3f4c;
|
||||||
|
--raise:#124a58;
|
||||||
|
--line:#1d5563;
|
||||||
|
--fg:#eee8d5; /* base2 */
|
||||||
|
--dim:#c3c7be; /* between base2 and base1: base1 had to lift to read on base02 */
|
||||||
|
--faint:#97a5a7; /* base0, lifted to base1 until it reads on base02 */
|
||||||
|
--accent:#4b9fda; /* blue */
|
||||||
|
--accent2:#b58900; /* yellow */
|
||||||
|
--ink:#002b36;
|
||||||
|
--good:#859900;
|
||||||
|
--warn:#bb9315;
|
||||||
|
--bad:#e87674;
|
||||||
|
--shadow:0 8px 28px rgba(0,0,0,.45);
|
||||||
|
}
|
||||||
|
:root[data-theme="solarized"][data-mode="light"] {
|
||||||
|
--bg:#fdf6e3; /* base3 */
|
||||||
|
--panel:#eee8d5; /* base2 */
|
||||||
|
--panel2:#e6dfca;
|
||||||
|
--raise:#ddd5bd;
|
||||||
|
--line:#d3cab0;
|
||||||
|
--fg:#073642; /* base02 */
|
||||||
|
--dim:#52666d; /* base01 */
|
||||||
|
--faint:#54666d; /* base00 */
|
||||||
|
--accent:#1e6da5; /* blue */
|
||||||
|
--accent2:#cb4b16; /* orange */
|
||||||
|
--ink:#ffffff;
|
||||||
|
--good:#768700;
|
||||||
|
--warn:#846400;
|
||||||
|
--bad:#c52d2a;
|
||||||
|
--shadow:0 8px 28px rgba(0,43,54,.14);
|
||||||
|
}
|
||||||
|
/* High contrast: black and white with no mid-greys, every pair at 7:1 (AAA) or more, and borders
|
||||||
|
that show. For reading, not for looks. */
|
||||||
|
:root[data-theme="contrast"] {
|
||||||
|
--bg:#000000;
|
||||||
|
--panel:#000000;
|
||||||
|
--panel2:#121212;
|
||||||
|
--raise:#333333;
|
||||||
|
--line:#9a9a9a;
|
||||||
|
--fg:#ffffff;
|
||||||
|
--dim:#ffffff;
|
||||||
|
--faint:#d6d6d6;
|
||||||
|
--accent:#8cc8ff;
|
||||||
|
--accent2:#ffd400;
|
||||||
|
--ink:#000000;
|
||||||
|
--good:#7ee787;
|
||||||
|
--warn:#ffd400;
|
||||||
|
--bad:#ff9a8f;
|
||||||
|
--shadow:0 0 0 1px #9a9a9a;
|
||||||
|
}
|
||||||
|
:root[data-theme="contrast"][data-mode="light"] {
|
||||||
|
--bg:#ffffff;
|
||||||
|
--panel:#ffffff;
|
||||||
|
--panel2:#f0f0f0;
|
||||||
|
--raise:#d6d6d6;
|
||||||
|
--line:#555555;
|
||||||
|
--fg:#000000;
|
||||||
|
--dim:#000000;
|
||||||
|
--faint:#303030;
|
||||||
|
--accent:#0033a0;
|
||||||
|
--accent2:#7a3d00;
|
||||||
|
--ink:#ffffff;
|
||||||
|
--good:#0a5c1c;
|
||||||
|
--warn:#6b4500;
|
||||||
|
--bad:#a30000;
|
||||||
|
--shadow:0 0 0 1px #555555;
|
||||||
|
}
|
||||||
/* Classic: the 2004 Mac app. Colours here; the chrome it needs is at the end of the sheet. */
|
/* Classic: the 2004 Mac app. Colours here; the chrome it needs is at the end of the sheet. */
|
||||||
:root[data-theme="classic"] {
|
:root[data-theme="classic"] {
|
||||||
color-scheme:light;
|
color-scheme:light;
|
||||||
@@ -250,7 +395,7 @@
|
|||||||
--fg:#000000;
|
--fg:#000000;
|
||||||
--dim:#444444;
|
--dim:#444444;
|
||||||
--faint:#666666;
|
--faint:#666666;
|
||||||
--accent:#3875d7; /* Aqua selection blue */
|
--accent:#3268c0; /* Aqua selection blue, a shade down so links clear AA on the source list */
|
||||||
--accent2:#2a5db0;
|
--accent2:#2a5db0;
|
||||||
--ink:#ffffff;
|
--ink:#ffffff;
|
||||||
--good:#237a23;
|
--good:#237a23;
|
||||||
@@ -258,6 +403,53 @@
|
|||||||
--bad:#c42b1c;
|
--bad:#c42b1c;
|
||||||
--shadow:0 4px 16px rgba(0,0,0,.28);
|
--shadow:0 4px 16px rgba(0,0,0,.28);
|
||||||
}
|
}
|
||||||
|
/* Glass, after Apple's Liquid Glass: Apple's system colours, on panels that let a soft coloured
|
||||||
|
wash show through. The hex values are what a panel reads as once blended, so the contrast check
|
||||||
|
still means something; the translucency and the wash are in the chrome at the end of the sheet. */
|
||||||
|
:root[data-theme="glass"] {
|
||||||
|
--bg:#0b0d14;
|
||||||
|
--panel:#161a24;
|
||||||
|
--panel2:#1f2430;
|
||||||
|
--raise:#2a3040;
|
||||||
|
--line:#343b4c;
|
||||||
|
--fg:#f5f5f7; /* Apple's label */
|
||||||
|
--dim:#aeaeb2; /* systemGray2 */
|
||||||
|
--faint:#98989f; /* systemGray, a shade up to clear AA over the wash */
|
||||||
|
--accent:#409cff; /* systemBlue's accessible dark variant; #0a84ff falls short as link text */
|
||||||
|
--accent2:#ff9f0a; /* systemOrange */
|
||||||
|
--ink:#000000;
|
||||||
|
--good:#30d158;
|
||||||
|
--warn:#ff9f0a;
|
||||||
|
--bad:#ff6961;
|
||||||
|
--shadow:0 10px 36px rgba(0,0,0,.45);
|
||||||
|
--glass-edge:rgba(255,255,255,.10);
|
||||||
|
--glass-hi:rgba(255,255,255,.14);
|
||||||
|
--wash1:rgba(64,120,255,.30);
|
||||||
|
--wash2:rgba(175,82,222,.24);
|
||||||
|
--wash3:rgba(48,176,199,.20);
|
||||||
|
}
|
||||||
|
:root[data-theme="glass"][data-mode="light"] {
|
||||||
|
--bg:#eef1f7;
|
||||||
|
--panel:#ffffff;
|
||||||
|
--panel2:#f2f4f8;
|
||||||
|
--raise:#e4e8f0;
|
||||||
|
--line:#d1d5de;
|
||||||
|
--fg:#1d1d1f;
|
||||||
|
--dim:#515154;
|
||||||
|
--faint:#5a5a5f; /* systemGray, taken down to clear AA over the wash */
|
||||||
|
--accent:#0055aa; /* a shade under Apple's #0066cc, which falls short over the wash */
|
||||||
|
--accent2:#b25000; /* systemOrange taken down until white on it clears AA */
|
||||||
|
--ink:#ffffff;
|
||||||
|
--good:#248a3d;
|
||||||
|
--warn:#824200;
|
||||||
|
--bad:#b8000f;
|
||||||
|
--shadow:0 10px 36px rgba(30,50,90,.16);
|
||||||
|
--glass-edge:rgba(255,255,255,.65);
|
||||||
|
--glass-hi:rgba(255,255,255,.9);
|
||||||
|
--wash1:rgba(64,120,255,.22);
|
||||||
|
--wash2:rgba(175,82,222,.16);
|
||||||
|
--wash3:rgba(48,176,199,.18);
|
||||||
|
}
|
||||||
*{box-sizing:border-box}
|
*{box-sizing:border-box}
|
||||||
/* A rule that sets display beats the UA's [hidden], and several below do. */
|
/* A rule that sets display beats the UA's [hidden], and several below do. */
|
||||||
[hidden]{display:none!important}
|
[hidden]{display:none!important}
|
||||||
@@ -308,8 +500,22 @@ a{color:var(--accent)}
|
|||||||
}
|
}
|
||||||
.iconbtn:hover{background:var(--raise);color:var(--fg)}
|
.iconbtn:hover{background:var(--raise);color:var(--fg)}
|
||||||
/* One toolbar across the window, as the original had: grouped buttons, search on the right. */
|
/* One toolbar across the window, as the original had: grouped buttons, search on the right. */
|
||||||
|
/* The display's own intrusions -- the home indicator along the bottom, the notch at one side in
|
||||||
|
landscape. viewport-fit=cover hands the page the whole screen, which is what a standalone
|
||||||
|
shell and an iOS home-screen app both give it, so the bars along the edges pay for them in
|
||||||
|
padding. A browser with its own chrome reports nought and nothing moves. */
|
||||||
|
:root{
|
||||||
|
--safe-b:env(safe-area-inset-bottom,0px);
|
||||||
|
--safe-l:env(safe-area-inset-left,0px);
|
||||||
|
--safe-r:env(safe-area-inset-right,0px);
|
||||||
|
}
|
||||||
#topbar{
|
#topbar{
|
||||||
display:flex;align-items:center;gap:10px;padding:7px 12px;min-width:0;overflow:hidden;
|
display:flex;align-items:center;gap:10px;min-width:0;overflow:hidden;
|
||||||
|
/* Longhand, and it has to stay longhand: the minifier runs a calc() in a padding shorthand
|
||||||
|
into the value after it -- `calc(12px + var(--safe-r))7px` -- and the browser then throws
|
||||||
|
the whole declaration away, leaving the bar with no padding at all. */
|
||||||
|
padding-top:7px;padding-bottom:7px;
|
||||||
|
padding-left:calc(12px + var(--safe-l));padding-right:calc(12px + var(--safe-r));
|
||||||
background:var(--panel);border-bottom:1px solid var(--line);
|
background:var(--panel);border-bottom:1px solid var(--line);
|
||||||
}
|
}
|
||||||
#topbar .grow{flex:1}
|
#topbar .grow{flex:1}
|
||||||
@@ -638,7 +844,9 @@ body.playing .eq i:nth-child(3){animation-delay:-.6s}
|
|||||||
#player{
|
#player{
|
||||||
border-top:1px solid var(--line);background:var(--panel);
|
border-top:1px solid var(--line);background:var(--panel);
|
||||||
display:none;grid-template-columns:auto 1fr auto;gap:14px;align-items:center;
|
display:none;grid-template-columns:auto 1fr auto;gap:14px;align-items:center;
|
||||||
padding:9px 16px;box-shadow:0 -6px 24px rgba(6,10,16,.4);
|
padding-top:9px;padding-bottom:calc(9px + var(--safe-b));
|
||||||
|
padding-left:calc(16px + var(--safe-l));padding-right:calc(16px + var(--safe-r));
|
||||||
|
box-shadow:0 -6px 24px rgba(6,10,16,.4);
|
||||||
}
|
}
|
||||||
#player.on{display:grid}
|
#player.on{display:grid}
|
||||||
/* #audio is a <video> playing double duty as the audio element (see its tag). Only a video
|
/* #audio is a <video> playing double duty as the audio element (see its tag). Only a video
|
||||||
@@ -720,14 +928,20 @@ input[type=range]::-moz-range-thumb{width:12px;height:12px;border:0;border-radiu
|
|||||||
background:var(--raise);border:1px solid var(--line);border-radius:9px;
|
background:var(--raise);border:1px solid var(--line);border-radius:9px;
|
||||||
padding:9px 13px;font-size:13px;box-shadow:var(--shadow);animation:in .18s;max-width:340px;
|
padding:9px 13px;font-size:13px;box-shadow:var(--shadow);animation:in .18s;max-width:340px;
|
||||||
}
|
}
|
||||||
.toast.bad{border-color:var(--bad);color:var(--bad)}
|
/* On --panel, not a toast's --raise: the error colours are tuned to read on the page's grounds. */
|
||||||
|
.toast.bad{border-color:var(--bad);color:var(--bad);background:var(--panel)}
|
||||||
@keyframes in{from{opacity:0;transform:translateY(6px)}}
|
@keyframes in{from{opacity:0;transform:translateY(6px)}}
|
||||||
#burger,#dback{display:none}
|
#burger,#dback{display:none}
|
||||||
/* Totals for what is showing, along the bottom, as the original's status bar. */
|
/* Totals for what is showing, along the bottom, as the original's status bar. */
|
||||||
#status{
|
#status{
|
||||||
padding:3px 14px;min-height:22px;font-size:12px;color:var(--faint);background:var(--panel);
|
padding-top:3px;padding-bottom:calc(3px + var(--safe-b));
|
||||||
|
padding-left:calc(14px + var(--safe-l));padding-right:calc(14px + var(--safe-r));
|
||||||
|
min-height:22px;font-size:12px;color:var(--faint);background:var(--panel);
|
||||||
border-top:1px solid var(--line);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;
|
border-top:1px solid var(--line);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;
|
||||||
}
|
}
|
||||||
|
/* Only the last bar along the bottom owes the indicator anything, and with a player open that
|
||||||
|
is the player, not this. Without :has() the worst of it is a gap above the player bar. */
|
||||||
|
body:has(#player.on) #status{padding-bottom:3px}
|
||||||
/* The feed's own header, kept to one line so the table starts high, as it did. */
|
/* The feed's own header, kept to one line so the table starts high, as it did. */
|
||||||
.fhead.slim{align-items:center;gap:8px 12px;margin-bottom:10px;flex-wrap:wrap}
|
.fhead.slim{align-items:center;gap:8px 12px;margin-bottom:10px;flex-wrap:wrap}
|
||||||
.fhead.slim .art{width:44px;height:44px;font-size:15px;box-shadow:none}
|
.fhead.slim .art{width:44px;height:44px;font-size:15px;box-shadow:none}
|
||||||
@@ -751,7 +965,11 @@ input[type=range]::-moz-range-thumb{width:12px;height:12px;border:0;border-radiu
|
|||||||
#sidebar{position:fixed;inset:0 auto 0 0;width:min(300px,86vw);z-index:42;transform:translateX(-100%);transition:transform .2s;box-shadow:var(--shadow)}
|
#sidebar{position:fixed;inset:0 auto 0 0;width:min(300px,86vw);z-index:42;transform:translateX(-100%);transition:transform .2s;box-shadow:var(--shadow)}
|
||||||
#sidebar.open{transform:none}
|
#sidebar.open{transform:none}
|
||||||
#scrim{position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:41}
|
#scrim{position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:41}
|
||||||
#player{position:relative;z-index:39;padding:7px 10px;gap:8px}
|
#player{
|
||||||
|
position:relative;z-index:39;gap:8px;
|
||||||
|
padding-top:7px;padding-bottom:calc(7px + var(--safe-b));
|
||||||
|
padding-left:calc(10px + var(--safe-l));padding-right:calc(10px + var(--safe-r));
|
||||||
|
}
|
||||||
|
|
||||||
/* The feed list is reachable whether or not anything is playing. */
|
/* The feed list is reachable whether or not anything is playing. */
|
||||||
#burger{display:grid}
|
#burger{display:grid}
|
||||||
@@ -860,3 +1078,74 @@ input[type=range]::-moz-range-thumb{width:12px;height:12px;border:0;border-radiu
|
|||||||
/* Lists were white in the original; the pale blue-grey belongs to the source list alone. */
|
/* Lists were white in the original; the pale blue-grey belongs to the source list alone. */
|
||||||
:root[data-theme="classic"] .childrow{background:#fff}
|
:root[data-theme="classic"] .childrow{background:#fff}
|
||||||
:root[data-theme="classic"] .dt{background:linear-gradient(#80aae6,#3f78cf);color:#fff;padding:6px 12px;border-radius:4px}
|
:root[data-theme="classic"] .dt{background:linear-gradient(#80aae6,#3f78cf);color:#fff;padding:6px 12px;border-radius:4px}
|
||||||
|
|
||||||
|
/* ---------- Glass: after Apple's Liquid Glass ---------- */
|
||||||
|
/* Translucent panels over a coloured wash, blurred and saturated the way macOS and iOS do it.
|
||||||
|
Apple's glass also bends light at its edges; that needs an SVG displacement filter that only
|
||||||
|
Chromium applies to a backdrop, and only on a shape of fixed size, so it is left out (#43). */
|
||||||
|
:root[data-theme="glass"] body{
|
||||||
|
font-family:-apple-system,BlinkMacSystemFont,system-ui,Inter,"Segoe UI",Roboto,sans-serif;
|
||||||
|
background:
|
||||||
|
radial-gradient(60% 55% at 8% 0%,var(--wash1),transparent),
|
||||||
|
radial-gradient(50% 50% at 92% 18%,var(--wash2),transparent),
|
||||||
|
radial-gradient(60% 60% at 60% 100%,var(--wash3),transparent),
|
||||||
|
var(--bg);
|
||||||
|
}
|
||||||
|
:root[data-theme="glass"] #sidebar,
|
||||||
|
:root[data-theme="glass"] #topbar,
|
||||||
|
:root[data-theme="glass"] #player,
|
||||||
|
:root[data-theme="glass"] #status,
|
||||||
|
:root[data-theme="glass"] #files,
|
||||||
|
:root[data-theme="glass"] #detail,
|
||||||
|
:root[data-theme="glass"] .card,
|
||||||
|
:root[data-theme="glass"] .toast{
|
||||||
|
background:color-mix(in srgb,var(--panel) 70%,transparent);
|
||||||
|
-webkit-backdrop-filter:blur(24px) saturate(180%);
|
||||||
|
backdrop-filter:blur(24px) saturate(180%);
|
||||||
|
border-color:var(--glass-edge);
|
||||||
|
box-shadow:inset 0 1px 0 var(--glass-hi);
|
||||||
|
}
|
||||||
|
:root[data-theme="glass"] .toolbar,
|
||||||
|
:root[data-theme="glass"] .ephead{
|
||||||
|
/* Sticky, so the list scrolls under them: the one place the frosting has something to blur. */
|
||||||
|
background:color-mix(in srgb,var(--bg) 65%,transparent);
|
||||||
|
-webkit-backdrop-filter:blur(20px) saturate(180%);
|
||||||
|
backdrop-filter:blur(20px) saturate(180%);
|
||||||
|
}
|
||||||
|
:root[data-theme="glass"] .card,
|
||||||
|
:root[data-theme="glass"] .toast{box-shadow:inset 0 1px 0 var(--glass-hi),var(--shadow)}
|
||||||
|
:root[data-theme="glass"] .card{border-radius:20px}
|
||||||
|
:root[data-theme="glass"] .toast{border-radius:14px}
|
||||||
|
:root[data-theme="glass"] .toast.bad{border-color:var(--bad)}
|
||||||
|
/* A narrow screen slides the feed list over the page, so it keeps its shadow. */
|
||||||
|
@media (max-width:820px){
|
||||||
|
:root[data-theme="glass"] #sidebar{box-shadow:inset -1px 0 0 var(--glass-hi),var(--shadow)}
|
||||||
|
}
|
||||||
|
/* The card is the glass; a lighter scrim leaves something behind it to see through. */
|
||||||
|
:root[data-theme="glass"] #modal{background:rgba(0,0,0,.3)}
|
||||||
|
:root[data-theme="glass"] .tgroup,
|
||||||
|
:root[data-theme="glass"] .tabs{
|
||||||
|
background:color-mix(in srgb,var(--panel2) 60%,transparent);
|
||||||
|
border-color:var(--glass-edge);border-radius:12px;box-shadow:inset 0 1px 0 var(--glass-hi);
|
||||||
|
}
|
||||||
|
:root[data-theme="glass"] .btn,
|
||||||
|
:root[data-theme="glass"] .sidetools button,
|
||||||
|
:root[data-theme="glass"] .sidefoot button{border-radius:10px}
|
||||||
|
:root[data-theme="glass"] .btn.primary{box-shadow:inset 0 1px 0 rgba(255,255,255,.35)}
|
||||||
|
/* Someone who has asked the system for less transparency, or more contrast, gets solid panels. */
|
||||||
|
@media (prefers-reduced-transparency:reduce),(prefers-contrast:more){
|
||||||
|
:root[data-theme="glass"] body{background:var(--bg)}
|
||||||
|
:root[data-theme="glass"] #sidebar,
|
||||||
|
:root[data-theme="glass"] #topbar,
|
||||||
|
:root[data-theme="glass"] #player,
|
||||||
|
:root[data-theme="glass"] #status,
|
||||||
|
:root[data-theme="glass"] #files,
|
||||||
|
:root[data-theme="glass"] #detail,
|
||||||
|
:root[data-theme="glass"] .card,
|
||||||
|
:root[data-theme="glass"] .toast,
|
||||||
|
:root[data-theme="glass"] .tgroup,
|
||||||
|
:root[data-theme="glass"] .tabs{background:var(--panel);-webkit-backdrop-filter:none;backdrop-filter:none;border-color:var(--line)}
|
||||||
|
:root[data-theme="glass"] .toolbar,
|
||||||
|
:root[data-theme="glass"] .ephead{background:var(--bg);-webkit-backdrop-filter:none;backdrop-filter:none}
|
||||||
|
:root[data-theme="glass"] #modal{background:rgba(0,0,0,.6)}
|
||||||
|
}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ const here = path.dirname(fileURLToPath(import.meta.url));
|
|||||||
// The files are one script, concatenated in this order, not modules: they share one top-level
|
// The files are one script, concatenated in this order, not modules: they share one top-level
|
||||||
// scope, as the single inline script did, and code that runs at load needs what came before it.
|
// scope, as the single inline script did, and code that runs at load needs what came before it.
|
||||||
const PAGES = {
|
const PAGES = {
|
||||||
'index.html': { script: 'app.js', src: ['util', 'theme', 'feeds', 'feedpage', 'items', 'player', 'dialogs', 'gestures', 'events'] },
|
'index.html': { script: 'app.js', src: ['util', 'theme', 'feeds', 'feedpage', 'items', 'player', 'dialogs', 'gestures', 'events', 'native'] },
|
||||||
'admin.html': { script: 'admin.js', src: ['util', 'theme', 'admin'] },
|
'admin.html': { script: 'admin.js', src: ['util', 'theme', 'admin'] },
|
||||||
'login.html': { script: 'login.js', src: ['login'] },
|
'login.html': { script: 'login.js', src: ['login'] },
|
||||||
};
|
};
|
||||||
@@ -38,7 +38,14 @@ export function buildStyle({ minify = true } = {}) {
|
|||||||
const r = html.minifySync(`<!doctype html><style>${css}</style>`, { minifyCss: true, removeComments: true });
|
const r = html.minifySync(`<!doctype html><style>${css}</style>`, { minifyCss: true, removeComments: true });
|
||||||
const bad = (r.errors || []).filter(e => e.level === 'error' || e.level === 'Error');
|
const bad = (r.errors || []).filter(e => e.level === 'error' || e.level === 'Error');
|
||||||
if (bad.length) throw new Error(`${STYLE}: ${bad.map(e => e.message).join('; ')}`);
|
if (bad.length) throw new Error(`${STYLE}: ${bad.map(e => e.message).join('; ')}`);
|
||||||
return r.code.slice(r.code.indexOf('<style>') + 7, r.code.lastIndexOf('</style>'));
|
const out = r.code.slice(r.code.indexOf('<style>') + 7, r.code.lastIndexOf('</style>'));
|
||||||
|
// The minifier drops the space between a calc() and the value after it in a shorthand --
|
||||||
|
// `padding:7px calc(12px + var(--safe-r))7px ...` -- and a browser throws the whole
|
||||||
|
// declaration away, so the element silently loses its padding. It reports no error and the
|
||||||
|
// page still loads, which is why this is checked rather than trusted. Longhands avoid it.
|
||||||
|
const run = out.match(/calc\([^()]*(?:\([^()]*\)[^()]*)*\)(?=[0-9a-zA-Z.])/);
|
||||||
|
if (run) throw new Error(`${STYLE}: minifying ran ${run[0]} into the value after it; use longhand properties`);
|
||||||
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The page and its script, built: { html, js, script }, where script is the file's name.
|
/// The page and its script, built: { html, js, script }, where script is the file's name.
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||||
<meta name="color-scheme" content="dark light">
|
<meta name="color-scheme" content="dark light">
|
||||||
<title>iPodderX</title>
|
<title>iPodderX</title>
|
||||||
<link rel="icon" type="image/png" sizes="128x128" href="/favicon.png">
|
<link rel="icon" type="image/png" sizes="128x128" href="/favicon.png">
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
--line:#2c3849;
|
--line:#2c3849;
|
||||||
--fg:#f5f5f5; /* #F5F5F5 device highlight */
|
--fg:#f5f5f5; /* #F5F5F5 device highlight */
|
||||||
--dim:#95a0b1; /* #95A0B1 straight from the icon's blue-grey */
|
--dim:#95a0b1; /* #95A0B1 straight from the icon's blue-grey */
|
||||||
--faint:#7a8799; /* lifted from the icon ramp until it clears AA at small sizes */
|
--faint:#7e8b9c; /* lifted from the icon ramp until it clears AA at small sizes */
|
||||||
--accent:#92b2e6; /* #92B2E6 the screen blue */
|
--accent:#92b2e6; /* #92B2E6 the screen blue */
|
||||||
--accent2:#f49e2c; /* #F49E2C the EQ bars */
|
--accent2:#f49e2c; /* #F49E2C the EQ bars */
|
||||||
--ink:#0e131b; /* text on an accent fill */
|
--ink:#0e131b; /* text on an accent fill */
|
||||||
|
|||||||
177
web/src/native.ts
Normal file
177
web/src/native.ts
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
/* ---------------- native shell bridge ---------------- */
|
||||||
|
// Inside the iOS or Android app this page is a WebView, and the audio it plays has to come from
|
||||||
|
// the host's own player instead of this element: CarPlay and Android Auto are template surfaces
|
||||||
|
// that cannot render a WebView at all, and the only audio they will control is the host's.
|
||||||
|
//
|
||||||
|
// So the host's player takes over, and the element keeps its face. Everything in player.ts speaks
|
||||||
|
// to `audio` through a small surface -- play, pause, src, currentTime, duration, paused,
|
||||||
|
// readyState, volume, playbackRate, and the events it fires -- so replacing that surface on the
|
||||||
|
// element leaves the player bar, the row buttons, the EQ bars and the keyboard shortcuts working
|
||||||
|
// exactly as they do in a browser, with nothing in player.ts changed.
|
||||||
|
//
|
||||||
|
// In a browser none of this installs and the page is untouched.
|
||||||
|
|
||||||
|
/// How the host is reached. iOS puts a handler on `webkit.messageHandlers`; Android's
|
||||||
|
/// `addJavascriptInterface` gives a plain object with a `postMessage(string)`. Null in a browser,
|
||||||
|
/// which is what switches the whole file off.
|
||||||
|
const ipxHost: ((m: any) => void) | null = (() => {
|
||||||
|
const w = window as any;
|
||||||
|
const ios = w.webkit?.messageHandlers?.ipx;
|
||||||
|
if (ios) return (m: any) => ios.postMessage(m);
|
||||||
|
const android = w.ipxAndroid;
|
||||||
|
if (android?.postMessage) return (m: any) => android.postMessage(JSON.stringify(m));
|
||||||
|
return null;
|
||||||
|
})();
|
||||||
|
|
||||||
|
if (ipxHost) installNativePlayback();
|
||||||
|
|
||||||
|
function installNativePlayback(){
|
||||||
|
const post = ipxHost!;
|
||||||
|
const M = HTMLMediaElement.prototype;
|
||||||
|
const own = (k: string) => Object.getOwnPropertyDescriptor(M, k)!;
|
||||||
|
const realPlay = M.play, realPause = M.pause, realLoad = M.load;
|
||||||
|
// Bound before anything is redefined, because the src setter below has to drop the element's
|
||||||
|
// file without that counting as closing the player: removeAttribute is overridden further down
|
||||||
|
// to mean exactly that, and going through it there switched the shim straight back off.
|
||||||
|
const realRemoveAttribute = audio.removeAttribute.bind(audio);
|
||||||
|
const src = own('src'), currentTime = own('currentTime'), duration = own('duration');
|
||||||
|
const paused = own('paused'), readyState = own('readyState');
|
||||||
|
const volume = own('volume'), playbackRate = own('playbackRate');
|
||||||
|
|
||||||
|
// What the host last told us. `on` is the whole switch: false means this element is playing for
|
||||||
|
// itself, which is still the case for video -- the host plays audio, and a native video layer
|
||||||
|
// under a WebView buys nothing when CarPlay is audio-only either way.
|
||||||
|
const N = {on:false, cur:0, dur:NaN, paused:true, ready:0};
|
||||||
|
|
||||||
|
const fire = (name: string) => audio.dispatchEvent(new Event(name));
|
||||||
|
|
||||||
|
const define = (k: string, d: PropertyDescriptor) =>
|
||||||
|
Object.defineProperty(audio, k, {configurable:true, ...d});
|
||||||
|
|
||||||
|
define('play', {value(){
|
||||||
|
if(!N.on) return realPlay.call(audio);
|
||||||
|
post({t:'play'});
|
||||||
|
// player.ts does audio.play().catch(...) to toast a failure. A failure here arrives as a
|
||||||
|
// message from the host instead, so there is nothing to reject.
|
||||||
|
return Promise.resolve();
|
||||||
|
}});
|
||||||
|
define('pause', {value(){
|
||||||
|
if(!N.on) return realPause.call(audio);
|
||||||
|
post({t:'pause'});
|
||||||
|
}});
|
||||||
|
|
||||||
|
define('src', {
|
||||||
|
get(){ return N.on ? '' : src.get!.call(audio); },
|
||||||
|
set(v){
|
||||||
|
// has-video is set immediately before the src in play(), so it is already right here.
|
||||||
|
if(document.body.classList.contains('has-video')){
|
||||||
|
stop();
|
||||||
|
src.set!.call(audio, v);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
N.on = true; N.cur = 0; N.dur = NaN; N.paused = true; N.ready = 0;
|
||||||
|
// Let go of whatever the element was holding, or a video just closed keeps its buffer and
|
||||||
|
// its audio track. removeAttribute alone does not: it takes a load() to act on it.
|
||||||
|
realPause.call(audio);
|
||||||
|
realRemoveAttribute('src');
|
||||||
|
realLoad.call(audio);
|
||||||
|
const e = player.entry, f = player.feed;
|
||||||
|
post({t:'load', url:v, enc:player.enc, feedId:f, guid:player.guid,
|
||||||
|
title:(e && e.title) || '', feedTitle:feedName(f),
|
||||||
|
artwork:(e && e.image) || feedArt(f) || null,
|
||||||
|
// Where the host starts is not this: the seek to where you left off is player.ts's, on
|
||||||
|
// loadedmetadata, so one piece of code decides it. This is for the host's now-playing
|
||||||
|
// display before the file has loaded.
|
||||||
|
position:(e && e.position) || 0, duration:(e && e.duration) || null,
|
||||||
|
rate:audio.playbackRate, volume:audio.volume});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
define('currentTime', {
|
||||||
|
get(){ return N.on ? N.cur : currentTime.get!.call(audio); },
|
||||||
|
set(v){
|
||||||
|
if(!N.on){ currentTime.set!.call(audio, v); return; }
|
||||||
|
N.cur = v;
|
||||||
|
post({t:'seek', to:v});
|
||||||
|
// The clock and the scrubber move now rather than at the host's next tick, which is what
|
||||||
|
// makes the 15 and 30 second keys feel like they did.
|
||||||
|
fire('timeupdate');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
define('duration', {get(){ return N.on ? N.dur : duration.get!.call(audio); }});
|
||||||
|
define('paused', {get(){ return N.on ? N.paused : paused.get!.call(audio); }});
|
||||||
|
define('readyState', {get(){ return N.on ? N.ready : readyState.get!.call(audio); }});
|
||||||
|
|
||||||
|
define('volume', {
|
||||||
|
get(){ return volume.get!.call(audio); },
|
||||||
|
set(v){ volume.set!.call(audio, v); if(N.on) post({t:'volume', v}); },
|
||||||
|
});
|
||||||
|
define('playbackRate', {
|
||||||
|
get(){ return playbackRate.get!.call(audio); },
|
||||||
|
set(v){ playbackRate.set!.call(audio, v); if(N.on) post({t:'rate', v}); },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Closing the player is `audio.removeAttribute('src')`, which would otherwise leave the host
|
||||||
|
// playing on with nothing on screen to stop it.
|
||||||
|
define('removeAttribute', {value(name: string){
|
||||||
|
if(name === 'src') stop();
|
||||||
|
return realRemoveAttribute(name);
|
||||||
|
}});
|
||||||
|
|
||||||
|
function stop(){
|
||||||
|
if(!N.on) return;
|
||||||
|
N.on = false; N.paused = true; N.cur = 0; N.dur = NaN; N.ready = 0;
|
||||||
|
post({t:'stop'});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Position belongs to the host. player.ts is emphatic about what a stale write costs -- a player
|
||||||
|
// left paused in another tab once saved its older place over where you had got to -- and a
|
||||||
|
// backgrounded WebView is exactly that tab: frozen, holding a time from minutes ago, while the
|
||||||
|
// host plays on. So the beacon becomes a request for the host to save its own time, and the host
|
||||||
|
// is also the one saving while nothing here is running at all.
|
||||||
|
const beacon = navigator.sendBeacon && navigator.sendBeacon.bind(navigator);
|
||||||
|
navigator.sendBeacon = function(url: string, data?: any){
|
||||||
|
if(N.on && /\/position$/.test(String(url))){ post({t:'position', url:String(url)}); return true; }
|
||||||
|
return beacon ? beacon(url, data) : false;
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
// What the host calls back into. On `window` deliberately: the host reaches it by name through
|
||||||
|
// evaluateJavaScript, and a top-level const would work but not obviously.
|
||||||
|
(window as any).ipxNative = {
|
||||||
|
version: 1,
|
||||||
|
on(m: any){
|
||||||
|
if(!N.on) return;
|
||||||
|
switch(m.t){
|
||||||
|
case 'time':
|
||||||
|
N.cur = m.cur;
|
||||||
|
if(m.dur != null) N.dur = m.dur;
|
||||||
|
fire('timeupdate');
|
||||||
|
break;
|
||||||
|
case 'meta':
|
||||||
|
N.dur = m.dur; N.ready = 1;
|
||||||
|
fire('loadedmetadata');
|
||||||
|
break;
|
||||||
|
case 'state':
|
||||||
|
if(m.playing === !N.paused) return;
|
||||||
|
N.paused = !m.playing;
|
||||||
|
fire(m.playing ? 'play' : 'pause');
|
||||||
|
break;
|
||||||
|
case 'ended':
|
||||||
|
N.paused = true;
|
||||||
|
fire('pause');
|
||||||
|
fire('ended');
|
||||||
|
break;
|
||||||
|
case 'error':
|
||||||
|
N.paused = true;
|
||||||
|
fire('pause');
|
||||||
|
toast('Playback failed' + (m.message ? ': ' + m.message : ''), true);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// The host waits for this to know the bridge is in and which build it got: an app newer than the
|
||||||
|
// deployed page would otherwise sit there sending messages nothing answers.
|
||||||
|
post({t:'ready', version:1, rate:audio.playbackRate, volume:audio.volume});
|
||||||
|
}
|
||||||
@@ -5,14 +5,19 @@
|
|||||||
// page gets data-mode, light or dark, which is all the CSS reads: Auto is worked out here, from
|
// page gets data-mode, light or dark, which is all the CSS reads: Auto is worked out here, from
|
||||||
// the system, so no palette is written twice.
|
// the system, so no palette is written twice.
|
||||||
const THEMES: Record<string, {name: string, modes: boolean}> = {
|
const THEMES: Record<string, {name: string, modes: boolean}> = {
|
||||||
modern: {name: 'Modern', modes: true},
|
adwaita: {name: 'Adwaita', modes: true},
|
||||||
|
catppuccin: {name: 'Catppuccin', modes: true},
|
||||||
classic: {name: 'Classic', modes: false},
|
classic: {name: 'Classic', modes: false},
|
||||||
dracula: {name: 'Dracula', modes: true},
|
dracula: {name: 'Dracula', modes: true},
|
||||||
material: {name: 'Material', modes: true},
|
|
||||||
adwaita: {name: 'Adwaita', modes: true},
|
|
||||||
flatremix: {name: 'Flat Remix', modes: true},
|
flatremix: {name: 'Flat Remix', modes: true},
|
||||||
paper: {name: 'Paper', modes: false},
|
glass: {name: 'Glass', modes: true},
|
||||||
|
gruvbox: {name: 'Gruvbox', modes: true},
|
||||||
|
contrast: {name: 'High contrast', modes: true},
|
||||||
|
material: {name: 'Material', modes: true},
|
||||||
|
modern: {name: 'Modern', modes: true},
|
||||||
nordic: {name: 'Nordic', modes: true},
|
nordic: {name: 'Nordic', modes: true},
|
||||||
|
paper: {name: 'Paper', modes: false},
|
||||||
|
solarized: {name: 'Solarized', modes: true},
|
||||||
};
|
};
|
||||||
const MODES: Record<string, string> = {auto: 'Auto (matches your system)', light: 'Light', dark: 'Dark'};
|
const MODES: Record<string, string> = {auto: 'Auto (matches your system)', light: 'Light', dark: 'Dark'};
|
||||||
// Before themes came in light and dark, ipx.theme in localStorage held one of these.
|
// Before themes came in light and dark, ipx.theme in localStorage held one of these.
|
||||||
|
|||||||
Reference in New Issue
Block a user