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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
version: 2
updates:
- package-ecosystem: npm
directory: "/web"
groups:
web-dependencies:
patterns:
- "*"
schedule:
interval: weekly

- package-ecosystem: gradle
directory: "/"
groups:
Expand Down
10 changes: 10 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,13 @@ jobs:
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

- name: Set up Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version-file: web/.nvmrc
cache: npm
cache-dependency-path: web/package-lock.json

- name: Set up JDK 21
uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1
with:
Expand All @@ -37,6 +44,9 @@ jobs:
storyteller_f_sign_store_password: ${{ secrets.STORE_PASSWORD }}
storyteller_f_sign_key_password: ${{ secrets.KEY_PASSWORD }}

- name: Verify packaged web resources
run: python3 scripts/check-web-apk.py web/dist app/build/outputs/apk/*/*.apk

- name: Upload artifact
if: "!startsWith(github.ref, 'refs/tags/')"
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
Expand Down
22 changes: 22 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,20 +11,42 @@ jobs:
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

- name: Set up Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version-file: web/.nvmrc
cache: npm
cache-dependency-path: web/package-lock.json

- name: Set up JDK 21
uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1
with:
java-version: '21'
distribution: 'temurin'
cache: gradle

- name: Install web dependencies
run: npm ci --prefix web

- name: Test web state and Markdown
run: npm --prefix web test

- name: Install Chromium
run: npm --prefix web exec -- playwright install --with-deps chromium

- name: Test the bundled browser UI
run: npm --prefix web run test:browser

- name: Run tests
run: ./gradlew test

# PR builds do not need the upstream signing secrets. Exercise R8 as well as tests.
- name: Build minified APKs
run: ./gradlew :app:assembleAlpha :app:assembleRelease

- name: Verify web resources in minified APKs
run: python3 scripts/check-web-apk.py web/dist app/build/outputs/apk/alpha/*.apk app/build/outputs/apk/release/*.apk

- name: Sign disposable minified APK copies
run: scripts/sign-smoke-apks.sh "$RUNNER_TEMP/minified-smoke" app/build/outputs/apk/alpha/app-x86_64-alpha-unsigned.apk app/build/outputs/apk/release/app-x86_64-release-unsigned.apk

Expand Down
6 changes: 4 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,7 @@ local.properties
/.idea/deploymentTargetDropDown.xml
.kotlin
# Browser development tools and screenshots
/tests/web/node_modules/
/tests/web/artifacts/
/web/node_modules/
/web/test/artifacts/

/web/dist/
115 changes: 66 additions & 49 deletions DEVELOPMENT.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
# Development

Use JDK 21 or later and an Android SDK with platform 37. Set `ANDROID_HOME` to the SDK
directory (or configure `sdk.dir` in your local `local.properties`).
directory (or configure `sdk.dir` in your local `local.properties`). Install Node.js
24 and npm on `PATH` as well; Android builds compile the shared browser UI automatically.

```sh
./gradlew :app:testDebugUnitTest :app:assembleDebug
Expand Down Expand Up @@ -204,60 +205,76 @@ have a timeout and propagate failures instead of swallowing them.

## Shared browser UI

The production pages live in `app/src/main/resources/feiya`: `index.html`, `login.html`,
and `chat.html`. Shared styles, JavaScript, and icons live in the sibling `web/` directory.
The page namespace avoids collisions with dependency resources such as `index.html`.
Ktor serves
`/login` and `/web/*` before authentication, and `/` and `/messages` after session
validation. `/shares`, `/shares/{index}`, `/sse`, and `/chat` retain their existing
protocols. File downloads still use server list indices.
`web/` is a standalone npm project. HTML lives in `web/src/pages`, and the app,
Worker, Host, Markdown adapter, and stylesheet live in `web/src`. Runtime libraries
(`markdown-it` and `@phosphor-icons/core`) are declared in `web/package.json` and
locked in `web/package-lock.json`. Build/test tools are devDependencies. Dependabot
checks `/web` weekly, including transitive updates through the lockfile.

`web/host.js` owns asynchronous feature state and effects without DOM dependencies.
`web/worker.js` injects browser networking and timers in one dedicated Worker per
page. `web/app.js` renders snapshots and owns the immediately updated text draft.
Page exit terminates the Worker; a back/forward cache restore starts a fresh Host.
Messages are not automatically replayed after disconnect. Clipboard copying uses
the secure-context API when available and a user-gesture fallback for LAN HTTP.

Run the isolated web checks with Node.js 22 or later:
Use Node.js 24 (`web/.nvmrc`) and npm:

```sh
npm ci --prefix tests/web
npm --prefix tests/web test
npm --prefix tests/web exec -- playwright install --with-deps chromium
npm --prefix tests/web run test:browser
npm --prefix tests/web run preview
npm ci --prefix web
npm --prefix web run build
npm --prefix web test
npm --prefix web exec -- playwright install --with-deps chromium
npm --prefix web run test:browser
npm --prefix web run preview
```

The preview runs on `127.0.0.1:4173` with mock HTTP/SSE/WebSocket data; it is not an
Android end-to-end test. Browser tests save desktop/mobile screenshots to ignored
`tests/web/artifacts/`. `WebPagesTest` separately checks the real Ktor routes,
authentication redirects, login, and bundled assets using a JVM server.

Icons are vendored from [Phosphor Icons core 2.1.1](https://github.com/phosphor-icons/core)
under its MIT license (`web/icons/LICENSE`). They need no runtime package or CDN.
Use upstream SVG assets when adding icons and retain the license.

### Message Markdown

The Worker injects `web/markdown.mjs` into the Host and parses each incoming
message once. It ignores any HTML supplied by the sender and publishes locally
generated HTML alongside the original text. The DOM layer renders that markup;
copying always uses the original text. Raw HTML stays disabled, and markdown-it's
default dangerous-URL validation remains enabled. Links open separately with
`noopener noreferrer`; Markdown images can load their referenced URLs.

The browser bundle of markdown-it 15.0.2 and its license notices are checked in
under `web/vendor/markdown-it/`; there is no runtime CDN dependency. After updating
the pinned test-tool dependency and lockfile, regenerate the vendored files with:
`build` bundles JavaScript/CSS with esbuild, copies HTML and only referenced icon
assets, and derives third-party license notices from the actual bundled packages.
The output is `web/dist/feiya/*.html` and `web/dist/web/*`. Neither `dist` nor
`node_modules` is committed. No vendoring step is needed when dependencies change.
Update packages with npm and commit both `package.json` and `package-lock.json`.

### Android packaging

Every Android variant registers generated Java resources through the AGP variant
API. Gradle runs `:app:installWebDependencies` (`npm ci --include=dev`), then
`:app:buildWeb`, then copies the distribution into its variant-specific generated
resource directory. HTML, JS, CSS, icons, and notices enter the APK on the classpath;
source code, test fixtures, node_modules, and source maps do not. npm installation
and bundling use declared inputs/outputs so unchanged builds skip both tasks.
A deleted node_modules directory or changed lockfile causes a fresh install.
Build failures fail the Android build instead of packaging an old distribution.

Normal commands such as `./gradlew :app:assembleDebug`, `:app:assembleAlpha`,
`:app:assembleRelease`, and `:app:testDebugUnitTest` generate the web resources
automatically. `./gradlew :app:buildWeb` runs just the web build through Gradle.
CI provisions Node.js for both test and release workflows; the PR workflow also
runs npm tests and Chromium checks against the bundled output. Both workflows
compare APK entries against the npm distribution using:

```sh
npm ci --prefix tests/web
npm --prefix tests/web run vendor:markdown
npm --prefix tests/web test
npm --prefix tests/web run test:browser
python3 scripts/check-web-apk.py web/dist app/build/outputs/apk/*/*.apk
```

The vendoring script copies the official browser bundle and license files, omitting
only its source-map reference. Keep raw HTML disabled and run the malicious-input
fixtures when changing parser options or adding plugins.
This checks for missing, stale, unexpected, or duplicate web resources and excludes
node_modules from the APK.

Ktor serves `/login` and `/web/*` before authentication, and `/` and `/messages`
after session validation. The `feiya/` output namespace avoids collisions with
dependency resources such as `index.html`. `/shares`, `/shares/{index}`, `/sse`,
and `/chat` retain their protocols. File downloads still use server list indices.

### State, Markdown, and verification

`web/src/host.js` owns asynchronous state and effects without DOM dependencies.
`web/src/worker.js` injects networking, timers, and the Markdown renderer in a
Worker per page. `web/src/app.js` renders snapshots and owns the synchronous draft.
Page exit terminates the Worker; back/forward cache restore starts a fresh Host.
Messages are not replayed after disconnect. Clipboard copying uses the secure API
when available and a user-gesture fallback for LAN HTTP.

The Worker parses each message once, ignoring any sender-supplied HTML. The page
renders locally generated HTML; copying uses the original text. Raw HTML stays
disabled, and markdown-it's dangerous-URL validation remains enabled. Links open
separately with `noopener noreferrer`; message images can load their referenced
URLs. Run unsafe-input fixtures when changing parser options or adding plugins.

The preview runs on `127.0.0.1:4173` with mock HTTP/SSE/WebSocket data and serves
the same distribution as the APK. It is not an Android end-to-end test. Browser
tests save screenshots to ignored `web/test/artifacts/`. `WebPagesTest` separately
checks real Ktor routes, session login, and generated JS/CSS/icon/notice resources
using a JVM server. The web build preserves local assets; no CDN is needed.
58 changes: 58 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import com.google.gson.stream.JsonWriter
import java.io.FileWriter
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import javax.inject.Inject

plugins {
alias(libs.plugins.android)
Expand All @@ -9,6 +10,63 @@ plugins {
alias(libs.plugins.compose.compiler)
}

// The browser is an independent npm project. Only its distribution becomes Java
// resources; source files and node_modules never enter the APK.
val webDirectory = rootProject.layout.projectDirectory.dir("web")
val webDist = webDirectory.dir("dist")
val npmExecutable = if (System.getProperty("os.name").startsWith("Windows")) "npm.cmd" else "npm"
val installWebDependencies = tasks.register<Exec>("installWebDependencies") {
group = "build"
description = "Install the browser project's locked npm dependencies."
workingDir(webDirectory)
commandLine(npmExecutable, "ci", "--include=dev", "--no-audit", "--no-fund")
inputs.files(webDirectory.file("package.json"), webDirectory.file("package-lock.json"),
webDirectory.file(".npmrc"), webDirectory.file(".nvmrc"))
inputs.property("nodeVersion", providers.exec { commandLine("node", "--version") }.standardOutput.asText)
inputs.property("npmVersion", providers.exec { commandLine(npmExecutable, "--version") }.standardOutput.asText)
outputs.dir(webDirectory.dir("node_modules"))
}
val buildWeb = tasks.register<Exec>("buildWeb") {
group = "build"
description = "Bundle the browser UI and npm dependencies."
dependsOn(installWebDependencies)
workingDir(webDirectory)
commandLine(npmExecutable, "run", "build")
inputs.dir(webDirectory.dir("src"))
inputs.dir(webDirectory.dir("scripts"))
inputs.files(webDirectory.file("package.json"), webDirectory.file("package-lock.json"))
inputs.files(installWebDependencies.map { it.outputs.files })
outputs.dir(webDist)
}

abstract class SyncWebResources : DefaultTask() {
@get:InputDirectory
@get:PathSensitive(PathSensitivity.RELATIVE)
abstract val distribution: DirectoryProperty

@get:OutputDirectory
abstract val outputDirectory: DirectoryProperty

@get:Inject
abstract val fileSystemOperations: FileSystemOperations

@TaskAction
fun copyDistribution() {
fileSystemOperations.sync {
from(distribution)
into(outputDirectory)
}
}
}

androidComponents.onVariants { variant ->
val webResources = tasks.register<SyncWebResources>("generate${variant.name.replaceFirstChar { it.uppercase() }}WebResources") {
dependsOn(buildWeb)
distribution.set(webDist)
}
variant.sources.resources?.addGeneratedSourceDirectory(webResources, SyncWebResources::outputDirectory)
}

val signPath: String? = System.getenv("storyteller_f_sign_path")
val signFile = signPath?.let(rootProject::file)
val signAlias: String? = System.getenv("storyteller_f_sign_alias")
Expand Down
21 changes: 0 additions & 21 deletions app/src/main/resources/web/icons/LICENSE

This file was deleted.

1 change: 0 additions & 1 deletion app/src/main/resources/web/icons/chat-dots.svg

This file was deleted.

1 change: 0 additions & 1 deletion app/src/main/resources/web/icons/circle-fill.svg

This file was deleted.

1 change: 0 additions & 1 deletion app/src/main/resources/web/icons/copy.svg

This file was deleted.

1 change: 0 additions & 1 deletion app/src/main/resources/web/icons/download-simple.svg

This file was deleted.

1 change: 0 additions & 1 deletion app/src/main/resources/web/icons/file-image.svg

This file was deleted.

1 change: 0 additions & 1 deletion app/src/main/resources/web/icons/file-pdf.svg

This file was deleted.

1 change: 0 additions & 1 deletion app/src/main/resources/web/icons/file-ppt.svg

This file was deleted.

1 change: 0 additions & 1 deletion app/src/main/resources/web/icons/file-zip.svg

This file was deleted.

1 change: 0 additions & 1 deletion app/src/main/resources/web/icons/file.svg

This file was deleted.

22 changes: 0 additions & 22 deletions app/src/main/resources/web/vendor/markdown-it/LICENSE

This file was deleted.

Loading
Loading