Compare commits

..

2 Commits

Author SHA1 Message Date
Maurice 02a12ce918 test(studio): a Studio save carries its revision, and the layers foot is drawn with its selection 2026-10-23 20:11:03 +02:00
Maurice 2f4644d434 feat(studio): groups, a layers panel, marquee, nudge, multi-resize, align and paste style
Elements can be grouped on a page and picked, moved, resized, aligned and
restacked as one. A layers panel lists the page front to back with lock,
visibility, rename and drag to restack. The sheet gains marquee selection,
arrow-key nudge, one box that resizes a multi-selection with Shift holding
the proportions, and the design-tool shortcuts. The inspector lines a
selection up and spaces it out, and copies and pastes a style.

The assistant gets the same through the group, ungroup, name and hide
operations, and describe_book_page lists the groups. The format gains the
optional group, name and hidden fields and a spread's group names, so
BOOK_SCHEMA_REV goes to 2.
2026-10-23 19:31:14 +02:00
4264 changed files with 28681 additions and 264170 deletions
-1
View File
@@ -1,7 +1,6 @@
node_modules
client/node_modules
server/node_modules
shared/node_modules
client/dist
shared/dist
data
@@ -14,7 +14,7 @@ jobs:
timeout-minutes: 45
steps:
- name: Close stale invalid-title issues
uses: actions/github-script@v9
uses: actions/github-script@v8
with:
script: |
const badTitles = [
@@ -15,7 +15,7 @@ jobs:
timeout-minutes: 45
steps:
- name: Close stale wrong-base-branch PRs
uses: actions/github-script@v9
uses: actions/github-script@v8
with:
script: |
const { data: pulls } = await github.rest.pulls.list({
+1 -1
View File
@@ -14,7 +14,7 @@ jobs:
timeout-minutes: 45
steps:
- name: Flag or redirect issue
uses: actions/github-script@v9
uses: actions/github-script@v8
with:
script: |
const title = context.payload.issue.title.trim();
+3 -66
View File
@@ -5,13 +5,6 @@ name: Build & Push Docker Image (Prerelease)
# :latest-pre, and tags the commit. Nothing moves :latest. Like the stable
# release it refuses a commit whose ci-ok is not green. docker.yml with
# bump=auto later finalizes the line once main carries it.
#
# A prerelease asks the TREK services issuer for a registration dialect of its
# own -pre.N version (registration-dialect.yml, as the stable release does)
# and builds it into the image; nothing of it is committed, dev keeps the
# module of the last stable release, which it gets when main is merged back
# into dev. Without one the image carries dev's module, with a warning when
# that is the original one or old.
on:
workflow_dispatch:
@@ -96,23 +89,10 @@ jobs:
echo "VERSION=$NEW_VERSION" >> "$GITHUB_OUTPUT"
echo "$STABLE to $NEW_VERSION"
# The registration dialect of this prerelease, fetched and checked in a job
# without write access. It never fails the build.
dialect:
needs: version-bump
uses: ./.github/workflows/registration-dialect.yml
with:
version: ${{ needs.version-bump.outputs.version }}
ref: ${{ needs.version-bump.outputs.sha }}
secrets:
TREK_DIALECT_TOKEN: ${{ secrets.TREK_DIALECT_TOKEN }}
build:
runs-on: ${{ matrix.runner }}
timeout-minutes: 120
needs: [version-bump, dialect]
# Whatever became of the dialect job: without its artifact the image carries dev's module.
if: ${{ !cancelled() && needs.version-bump.result == 'success' }}
needs: version-bump
strategy:
fail-fast: false
matrix:
@@ -131,51 +111,9 @@ jobs:
with:
ref: ${{ needs.version-bump.outputs.sha }}
- name: Take over the prerelease's dialect
id: take
uses: actions/download-artifact@v7
continue-on-error: true
with:
name: registration-dialect
path: ${{ runner.temp }}/dialect
# Into the build context only; the commit stays as it is. Without a
# dialect of its own the image speaks dev's module, which the issuer
# takes for new installations for 180 days after it was made (longer
# while it is the newest stable release's). That is warned about, never
# stopped on, and a date that cannot be read only warns as well.
- name: Place the dialect
env:
VERSION: ${{ needs.version-bump.outputs.version }}
TAKEN: ${{ steps.take.outcome }}
REPORT: ${{ matrix.platform == 'linux/amd64' }}
run: |
set -u
target=server/src/nest/service-identity/registration-dialect.ts
fetched="$RUNNER_TEMP/dialect/registration-dialect.ts"
if [ "$TAKEN" = success ] && [ -f "$fetched" ]; then
cp "$fetched" "$target"
echo "$VERSION registers in dialect $(sed -n "s/^const DIALECT = '\(.*\)';$/\1/p" "$target") of its own."
exit 0
fi
[ "$REPORT" = true ] || exit 0
id="$(sed -n "s/^const DIALECT = '\(.*\)';$/\1/p" "$target")"
created="$(sed -n "s/^ createdAt: '\(.*\)',$/\1/p" "$target")"
if [ "$id" = 0 ]; then
message="$VERSION got no dialect of its own and registers in the original one, which the issuer accepts only for a transition period."
elif made="$(date -u -d "$created" +%s 2>/dev/null)"; then
age=$(( ($(date -u +%s) - made) / 86400 ))
message="$VERSION got no dialect of its own and registers in dev's dialect $id from $created, $age days old."
[ "$age" -gt 150 ] || { echo "$message"; exit 0; }
else
message="$VERSION got no dialect of its own, and the date of dev's dialect cannot be read from $target."
fi
echo "::warning::$message"
printf '### Registration dialect\n%s\n' "$message" >> "$GITHUB_STEP_SUMMARY"
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
- uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
@@ -218,7 +156,6 @@ jobs:
runs-on: ubuntu-24.04
timeout-minutes: 120
needs: [version-bump, build]
if: ${{ !cancelled() && needs.build.result == 'success' }}
steps:
- uses: actions/checkout@v6
with:
@@ -236,7 +173,7 @@ jobs:
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
- uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
+23 -182
View File
@@ -2,29 +2,17 @@ name: Build & Push Docker Image
# The stable release. Dispatched by hand on main, it:
# 1. refuses to run unless ci-ok passed on the main commit it releases,
# 2. works out the version from the tags and the bump input,
# 3. asks the TREK services issuer for the registration dialect of that
# version and checks it, in a job without write access
# (registration-dialect.yml); without one the release ships the dialect
# already on main, but only while that one still has 90 days to run,
# 4. commits the bump to package.json, the lockfile, Chart.yaml and the
# dialect, tags it and pushes,
# 5. builds both platforms from exactly that bump commit (never from
# 2. works out the version from the tags and the bump input, commits the
# bump to package.json, the lockfile and Chart.yaml, tags it and pushes,
# 3. builds both platforms from exactly that bump commit (never from
# whatever main points to by then) and publishes :VERSION,
# :MAJOR.MINOR, :MAJOR and :latest,
# 6. publishes the Helm chart from the same commit.
# 4. publishes the Helm chart from the same commit.
#
# Big or risky changes go out as a prerelease first (docker-dev.yml, tagged
# :latest-pre and :<next>-pre.N); bump=auto here then finalizes that line.
# Without a prerelease in flight there is no default: the run stops until
# someone picks patch, minor or major.
#
# Each stable release registers new installations in a dialect of its own,
# which the issuer accepts for 180 days, and for as long as it is the newest
# release's; renewing a key needs none. dev gets the module when main is merged
# back into dev after the release; a prerelease asks the issuer for one of its
# own (docker-dev.yml). The issuer's side, and what to set up before the first
# release with a dialect, is in the operations guide of the TREK services.
on:
workflow_dispatch:
@@ -38,14 +26,6 @@ on:
description: "Type MAJOR (all caps) to confirm a major release"
type: string
default: ''
ci_from:
description: 'Take ci-ok from this earlier commit instead of the one on main, when main differs from it only under .github/ (a fix to a release workflow needs no second test run)'
type: string
default: ''
allow_stale_dialect:
description: 'Ship without a dialect of its own even if the one on main is the original one or has under 90 days left'
type: boolean
default: false
permissions:
contents: write
@@ -56,16 +36,13 @@ concurrency:
cancel-in-progress: false
jobs:
version:
version-bump:
if: github.repository == 'liketrek/TREK'
runs-on: ubuntu-24.04
timeout-minutes: 30
permissions:
contents: read
checks: read
timeout-minutes: 120
outputs:
version: ${{ steps.bump.outputs.VERSION }}
base: ${{ steps.base.outputs.SHA }}
sha: ${{ steps.bump.outputs.SHA }}
steps:
- name: Release from main only
if: github.ref != 'refs/heads/main'
@@ -73,17 +50,17 @@ jobs:
echo "::error::Dispatch this workflow on main, not on $GITHUB_REF."
exit 1
- uses: actions/create-github-app-token@v3
id: app-token
with:
app-id: ${{ secrets.RELEASE_APP_ID }}
private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
- uses: actions/checkout@v6
with:
fetch-depth: 0
fetch-tags: true
persist-credentials: false
# The bump job checks out exactly this commit, so what ci-ok was checked
# on is what gets released, however long the dialect takes.
- name: Note the commit being released
id: base
run: echo "SHA=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
token: ${{ steps.app-token.outputs.token }}
# The dev push, the main push and any manual run each leave a ci-ok on
# this commit. scripts/ci/ci-ok-verdict.mjs counts the newest attempt of
@@ -91,7 +68,6 @@ jobs:
# finished last to be green, so a later green run supersedes a flaky or
# cancelled one. It prints the link of every run it counted.
- name: Require a green ci-ok on the commit being released
if: inputs.ci_from == ''
env:
GH_TOKEN: ${{ github.token }}
run: |
@@ -101,43 +77,14 @@ jobs:
gh api "repos/$GITHUB_REPOSITORY/commits/$sha/check-runs?check_name=ci-ok&filter=all&per_page=100" > "$runs"
node scripts/ci/ci-ok-verdict.mjs "$runs" "$sha"
# ci_from: main carries only workflow changes on top of a commit that
# already passed. That commit must be an ancestor of main, everything
# outside .github/ must be identical, and one of its ci-ok runs must be
# green (its dev push, typically).
- name: Require a green ci-ok on the commit main's code comes from
if: inputs.ci_from != ''
env:
GH_TOKEN: ${{ github.token }}
CI_FROM: ${{ inputs.ci_from }}
run: |
set -euo pipefail
from="$(git rev-parse --verify "$CI_FROM^{commit}")"
if ! git merge-base --is-ancestor "$from" HEAD; then
echo "::error::$from is not an ancestor of main."
exit 1
fi
outside="$(git diff --name-only "$from" HEAD -- . ':(exclude).github/**')"
if [ -n "$outside" ]; then
echo "::error::main differs from $from outside .github/:"
echo "$outside"
exit 1
fi
green="$(gh api "repos/$GITHUB_REPOSITORY/commits/$from/check-runs?check_name=ci-ok&filter=all&per_page=100" \
--jq '[.check_runs[] | select(.status == "completed" and .conclusion == "success")] | length')"
if [ "$green" -lt 1 ]; then
echo "::error::$from has no green ci-ok."
exit 1
fi
echo "main differs from $from only under .github/, and $from has $green green ci-ok run(s)."
- name: Determine bump type and version
- name: Determine bump type and update version
id: bump
env:
BUMP_INPUT: ${{ inputs.bump || 'auto' }}
CONFIRM_MAJOR: ${{ inputs.confirm_major }}
run: |
set -eu
git fetch --tags
# Derive version from git tags, no package.json dependency
STABLE_TAG=$(git tag -l 'v[0-9]*.[0-9]*.[0-9]*' | grep -v '\-pre\.' | sort -V | tail -1)
@@ -196,107 +143,6 @@ jobs:
echo "VERSION=$NEW_VERSION" >> "$GITHUB_OUTPUT"
echo "$STABLE to $NEW_VERSION ($BUMP)"
# The registration dialect of this version, fetched and checked in a job
# without write access. It never fails; without an artifact from it the
# bump decides below whether the dialect on main may ship.
dialect:
needs: version
uses: ./.github/workflows/registration-dialect.yml
with:
version: ${{ needs.version.outputs.version }}
ref: ${{ needs.version.outputs.base }}
secrets:
TREK_DIALECT_TOKEN: ${{ secrets.TREK_DIALECT_TOKEN }}
# Runs whatever became of the dialect job, a timeout or a lost runner
# included: no artifact means the dialect on main, if it may still ship.
version-bump:
needs: [version, dialect]
if: ${{ !cancelled() && needs.version.result == 'success' }}
runs-on: ubuntu-24.04
timeout-minutes: 120
outputs:
sha: ${{ steps.commit.outputs.SHA }}
steps:
- uses: actions/create-github-app-token@v3
id: app-token
with:
app-id: ${{ secrets.RELEASE_APP_ID }}
private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
- uses: actions/checkout@v6
with:
ref: ${{ needs.version.outputs.base }}
fetch-depth: 0
fetch-tags: true
token: ${{ steps.app-token.outputs.token }}
- name: Take over the checked dialect
id: take
uses: actions/download-artifact@v7
continue-on-error: true
with:
name: registration-dialect
path: ${{ runner.temp }}/dialect
# Without a dialect of its own the release ships the one on main, but
# only while the issuer takes new registrations in it for 90 more days:
# dialect 0 closes on the issuer's transition date, and a dialect with
# little left would shut out new installations of this release soon after
# it is out. Then this stops before anything is committed, unless
# allow_stale_dialect says to ship anyway. The issuer keeps the newest
# release's dialect open past its date, so the date here is the least it
# has, not the most.
- name: Place the dialect
env:
VERSION: ${{ needs.version.outputs.version }}
TAKEN: ${{ steps.take.outcome }}
ALLOW_STALE: ${{ inputs.allow_stale_dialect }}
run: |
set -u
target=server/src/nest/service-identity/registration-dialect.ts
fetched="$RUNNER_TEMP/dialect/registration-dialect.ts"
dialect_id() { sed -n "s/^const DIALECT = '\(.*\)';$/\1/p" "$1"; }
report() {
echo "$2$1"
printf '### Registration dialect\n%s\n' "$1" >> "$GITHUB_STEP_SUMMARY"
}
if [ "$TAKEN" = success ] && [ -f "$fetched" ]; then
cp "$fetched" "$target"
report "$VERSION registers in dialect $(dialect_id "$target"), written by the issuer for it." ""
exit 0
fi
id="$(dialect_id "$target")"
created="$(sed -n "s/^ createdAt: '\(.*\)',$/\1/p" "$target")"
left=""
if [ -n "$id" ] && [ "$id" != 0 ] && made="$(date -u -d "$created" +%s 2>/dev/null)"; then
left=$(( (made + 180 * 86400 - $(date -u +%s)) / 86400 ))
fi
if [ -n "$left" ] && [ "$left" -ge 90 ]; then
report "$VERSION ships without a dialect of its own and registers in dialect $id from $created, which the issuer takes for new installations for $left more days at least. Its operator can write one by hand for the next patch." "::warning::"
exit 0
fi
if [ "$id" = 0 ]; then
what="the original registration, which the issuer accepts only until the end of its transition period"
elif [ -n "$left" ]; then
what="dialect $id from $created, which has $left days left"
else
what="a dialect whose id or date cannot be read from $target"
fi
if [ "$ALLOW_STALE" = true ]; then
report "$VERSION ships without a dialect of its own and registers in $what, as allow_stale_dialect asked." "::warning::"
exit 0
fi
report "$VERSION got no dialect of its own and would register in $what. Nothing was committed or pushed. Make sure the issuer runs with its dialect seed and release token and that TREK_DIALECT_TOKEN is set, then run the release again, or tick allow_stale_dialect to ship anyway." "::error::"
exit 1
- name: Update the version, commit, tag and push
id: commit
env:
NEW_VERSION: ${{ needs.version.outputs.version }}
run: |
set -eu
# Update all workspace + root package.json files and the root lockfile in one shot
npm version "$NEW_VERSION" --workspaces --include-workspace-root --no-git-tag-version
sed -i "s/^version: .*/version: $NEW_VERSION/" charts/trek/Chart.yaml
@@ -307,8 +153,7 @@ jobs:
# landed on main in the meantime.
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add package.json package-lock.json server/package.json client/package.json shared/package.json \
charts/trek/Chart.yaml server/src/nest/service-identity/registration-dialect.ts
git add package.json package-lock.json server/package.json client/package.json shared/package.json charts/trek/Chart.yaml
git commit -m "chore: bump version to $NEW_VERSION [skip ci]"
git tag -a "v$NEW_VERSION" -m "v$NEW_VERSION"
git push origin HEAD:main --follow-tags
@@ -319,9 +164,7 @@ jobs:
build:
runs-on: ${{ matrix.runner }}
timeout-minutes: 120
needs: [version, version-bump]
# version-bump may run after a failed dialect job; that alone must not skip what follows it.
if: ${{ !cancelled() && needs.version-bump.result == 'success' }}
needs: version-bump
strategy:
fail-fast: false
matrix:
@@ -342,7 +185,7 @@ jobs:
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
- uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
@@ -362,7 +205,7 @@ jobs:
outputs: type=image,name=mauriceboe/trek,push-by-digest=true,name-canonical=true,push=true
no-cache: true
build-args: |
APP_VERSION=${{ needs.version.outputs.version }}
APP_VERSION=${{ needs.version-bump.outputs.version }}
labels: |
org.opencontainers.image.revision=${{ steps.meta.outputs.revision }}
org.opencontainers.image.created=${{ steps.meta.outputs.created }}
@@ -384,8 +227,7 @@ jobs:
merge:
runs-on: ubuntu-24.04
timeout-minutes: 120
needs: [version, version-bump, build]
if: ${{ !cancelled() && needs.build.result == 'success' }}
needs: [version-bump, build]
steps:
- uses: actions/checkout@v6
with:
@@ -401,7 +243,7 @@ jobs:
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
- uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
@@ -410,7 +252,7 @@ jobs:
working-directory: /tmp/digests
run: |
set -euo pipefail
VERSION="${{ needs.version.outputs.version }}"
VERSION="${{ needs.version-bump.outputs.version }}"
MAJOR_TAG="$(echo "$VERSION" | cut -d. -f1)"
# :MAJOR.MINOR takes the patches of one minor line and nothing else,
# the conservative pin for an install that wants to wait out a
@@ -450,7 +292,6 @@ jobs:
runs-on: ubuntu-24.04
timeout-minutes: 120
needs: version-bump
if: ${{ !cancelled() && needs.version-bump.result == 'success' }}
steps:
- name: Checkout
uses: actions/checkout@v6
+1 -1
View File
@@ -16,7 +16,7 @@ jobs:
steps:
- name: Flag or clear wrong base branch
uses: actions/github-script@v9
uses: actions/github-script@v8
with:
script: |
const base = context.payload.pull_request.base.ref;
+1 -1
View File
@@ -36,7 +36,7 @@ jobs:
- name: Comment on PR if checks failed
if: steps.checks.outcome == 'failure'
uses: actions/github-script@v9
uses: actions/github-script@v8
with:
script: |
await github.rest.issues.createComment({
-181
View File
@@ -1,181 +0,0 @@
name: Native apps release
# Builds the signed store builds of the iOS and Android apps and uploads them:
# the Android App Bundle to a Google Play testing track, the iOS archive to
# App Store Connect (TestFlight). Promotion to production stays a manual step
# in both consoles.
#
# Runs only in liketrek/TREK, only by hand or on a version tag, and only after a
# required reviewer approves the `store` environment, which holds every secret
# (see wiki/Native-Apps.md for the list). Pull requests never reach it.
#
# Both stores get the same numbers: the version from client/package.json
# without a prerelease suffix, and a build number that only grows. A prerelease
# reuses its X.Y.Z with a higher build number and belongs on the internal track
# and in TestFlight only.
on:
workflow_dispatch:
inputs:
track:
description: Google Play track
type: choice
options: [internal, alpha, beta]
default: internal
push:
tags: ['v*']
permissions:
contents: read
concurrency:
group: native-release
cancel-in-progress: false
jobs:
android:
# Gated on a repository variable so the release can ship iOS alone while the
# Google Play account and its signing secrets are not yet in place. Set the
# repository variable NATIVE_RELEASE_ANDROID to 1 to turn the Android half on.
if: github.repository == 'liketrek/TREK' && vars.NATIVE_RELEASE_ANDROID == '1'
runs-on: ubuntu-24.04
timeout-minutes: 40
environment: store
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version-file: .nvmrc
cache: npm
- uses: actions/setup-java@v5
with:
distribution: temurin
java-version: 21
cache: gradle
- name: Version and build number
id: version
run: |
echo "version=$(node -p "require('./client/package.json').version.split('-')[0]")" >> "$GITHUB_OUTPUT"
echo "build=$((GITHUB_RUN_NUMBER + 1000))" >> "$GITHUB_OUTPUT"
- name: Install
run: npm ci --workspace=shared --workspace=client
- name: Build shared
run: npm run build --workspace=shared
- name: Build the address screen and sync it into the shell
working-directory: client
run: npm run cap:sync -- android
- name: Upload key
env:
KEYSTORE_B64: ${{ secrets.ANDROID_UPLOAD_KEYSTORE_B64 }}
run: printf '%s' "$KEYSTORE_B64" | base64 -d > "$RUNNER_TEMP/upload.jks"
- name: Build the signed App Bundle
working-directory: client/android
env:
ORG_GRADLE_PROJECT_trekKeystorePassword: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
ORG_GRADLE_PROJECT_trekKeyAlias: ${{ secrets.ANDROID_KEY_ALIAS }}
ORG_GRADLE_PROJECT_trekKeyPassword: ${{ secrets.ANDROID_KEY_PASSWORD }}
run: >-
./gradlew bundleRelease --no-daemon
-PtrekVersionName=${{ steps.version.outputs.version }}
-PtrekVersionCode=${{ steps.version.outputs.build }}
-PtrekKeystore="$RUNNER_TEMP/upload.jks"
- uses: actions/upload-artifact@v6
with:
name: trek-android-${{ steps.version.outputs.version }}-${{ steps.version.outputs.build }}
path: client/android/app/build/outputs/bundle/release/app-release.aab
retention-days: 30
# Play only accepts API uploads once one bundle was uploaded by hand
# (wiki/Native-Apps.md). `draft` until the app has left draft in the console.
- name: Upload to Google Play
uses: r0adkll/upload-google-play@e738b9dd8f2476ea806d921b64aacd24f34515a5 # v1.1.5
with:
serviceAccountJsonPlainText: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }}
packageName: com.liketrek.trek
releaseFiles: client/android/app/build/outputs/bundle/release/app-release.aab
track: ${{ inputs.track || 'internal' }}
status: draft
- name: Remove the upload key
if: always()
run: rm -f "$RUNNER_TEMP/upload.jks"
ios:
if: github.repository == 'liketrek/TREK'
runs-on: macos-15
timeout-minutes: 60
environment: store
steps:
- uses: actions/checkout@v6
# App Store uploads must be built with Xcode 26; the image's default is older.
- name: Select Xcode 26
run: |
XCODE=$(ls -d /Applications/Xcode_26*.app | sort -V | tail -1)
test -n "$XCODE"
sudo xcode-select -s "$XCODE"
xcodebuild -version
- uses: actions/setup-node@v6
with:
node-version-file: .nvmrc
cache: npm
- name: Version and build number
id: version
run: |
echo "version=$(node -p "require('./client/package.json').version.split('-')[0]")" >> "$GITHUB_OUTPUT"
echo "build=$((GITHUB_RUN_NUMBER + 1000))" >> "$GITHUB_OUTPUT"
- name: Install
run: npm ci --workspace=shared --workspace=client
- name: Build shared
run: npm run build --workspace=shared
- name: Build the address screen and sync it into the shell
working-directory: client
run: npm run cap:sync -- ios
- name: App Store Connect API key
env:
ASC_KEY_P8_B64: ${{ secrets.ASC_KEY_P8_B64 }}
run: printf '%s' "$ASC_KEY_P8_B64" | base64 -d > "$RUNNER_TEMP/AuthKey.p8"
- name: Distribution certificate
env:
APPLE_DIST_CERT_P12_B64: ${{ secrets.APPLE_DIST_CERT_P12_B64 }}
run: printf '%s' "$APPLE_DIST_CERT_P12_B64" | base64 -d > "$RUNNER_TEMP/dist.p12"
# Signs for distribution and uploads to TestFlight. Automatic signing cannot
# run on a device-less CI runner (it signs for development and then needs a
# development profile, which needs a registered device), so client/fastlane/Fastfile
# imports the stored distribution certificate and creates the App Store profile
# through the API key.
- name: Build and upload to TestFlight
working-directory: client
env:
ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
ASC_KEY_PATH: ${{ runner.temp }}/AuthKey.p8
CERT_P12_PATH: ${{ runner.temp }}/dist.p12
CERT_P12_PASSWORD: ${{ secrets.APPLE_DIST_CERT_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
MARKETING_VERSION: ${{ steps.version.outputs.version }}
CURRENT_PROJECT_VERSION: ${{ steps.version.outputs.build }}
FASTLANE_SKIP_UPDATE_CHECK: '1'
FASTLANE_HIDE_CHANGELOG: '1'
run: fastlane ios beta
- name: Remove secrets
if: always()
run: rm -f "$RUNNER_TEMP/AuthKey.p8" "$RUNNER_TEMP/dist.p12"
-132
View File
@@ -1,132 +0,0 @@
name: Native apps
permissions:
contents: read
# Builds the iOS and Android shells whenever they, their address screen or the
# Capacitor plugins change. Nobody on the team has to own a Mac for the Swift
# side to be compiled on every change, and every run leaves a debug APK to
# install on a phone.
#
# Unsigned on purpose: signing and store uploads need secrets that fork PRs do
# not get, and belong in a release workflow of their own.
on:
pull_request:
paths:
- 'client/android/**'
- 'client/ios/**'
- 'client/native-shell/**'
- 'client/src/native/trekShell.ts'
- 'client/src/native/serverAddress.ts'
- 'client/capacitor.config.ts'
- 'client/vite.native.config.js'
- 'client/scripts/check-store-metadata.mjs'
- 'client/fastlane/**'
- 'client/package.json'
- 'package-lock.json'
- 'shared/src/i18n/*/native.ts'
- '.github/workflows/native.yml'
workflow_dispatch:
concurrency:
group: native-${{ github.ref }}
cancel-in-progress: true
jobs:
# The store texts fit their limits and every listing image has its size and
# no alpha channel (client/fastlane/README.md). Plain Node, no install.
metadata:
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version-file: .nvmrc
- name: Check the store listings
run: node client/scripts/check-store-metadata.mjs
android:
runs-on: ubuntu-24.04
timeout-minutes: 30
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version-file: .nvmrc
cache: npm
- uses: actions/setup-java@v5
with:
distribution: temurin
java-version: 21
cache: gradle
- name: Install
run: npm ci --workspace=shared --workspace=client
- name: Build shared
run: npm run build --workspace=shared
- name: Build the address screen and sync it into the shells
working-directory: client
run: npm run cap:sync -- android
# The release build runs R8 with the keep rules in proguard-rules.pro, so a
# rule that goes missing fails here rather than in the store build. It is
# unsigned: the upload key only exists in native-release.yml.
- name: Assemble debug and release APKs
working-directory: client/android
run: ./gradlew assembleDebug assembleRelease lintDebug testDebugUnitTest --no-daemon
- uses: actions/upload-artifact@v6
with:
name: trek-android-debug
path: client/android/app/build/outputs/apk/debug/app-debug.apk
retention-days: 14
ios:
runs-on: macos-15
timeout-minutes: 40
steps:
- uses: actions/checkout@v6
# App Store uploads must be built with Xcode 26; the image's default is older.
- name: Select Xcode 26
run: |
XCODE=$(ls -d /Applications/Xcode_26*.app | sort -V | tail -1)
test -n "$XCODE"
sudo xcode-select -s "$XCODE"
xcodebuild -version
- name: Check the property lists
run: plutil -lint client/ios/App/App/Info.plist client/ios/ExportOptions.plist client/ios/App/NotificationService/Info.plist client/ios/App/NotificationService/NotificationService.entitlements client/ios/App/App/App.entitlements client/ios/App/TREKWidgets/Info.plist client/ios/App/TREKWidgets/TREKWidgets.entitlements
- uses: actions/setup-node@v6
with:
node-version-file: .nvmrc
cache: npm
- name: Install
run: npm ci --workspace=shared --workspace=client
- name: Build shared
run: npm run build --workspace=shared
- name: Build the address screen and sync it into the shells
working-directory: client
run: npm run cap:sync -- ios
- name: Build for the simulator
working-directory: client/ios/App
run: |
xcodebuild build \
-project App.xcodeproj \
-scheme App \
-configuration Debug \
-sdk iphonesimulator \
-destination 'generic/platform=iOS Simulator' \
CODE_SIGNING_ALLOWED=NO
-144
View File
@@ -1,144 +0,0 @@
name: Registration dialect
# Asks the TREK services issuer for the registration dialect of one version and
# checks it, for docker.yml (a stable release commits it with the version bump)
# and docker-dev.yml (a prerelease only builds it into its image).
#
# The issuer's answer runs here, in a job that holds no write token and keeps
# no git credentials; the token for the issuer is in the environment of the
# one step that fetches. The answer is held to what a dialect is
# (scripts/ci/check-registration-dialect.mjs), then type-checked and linted
# the way TREK's CI would see it once it is on main and dev. Only a module that
# passes all of it goes on, as the artifact `registration-dialect`; without
# one the caller decides what ships. This workflow never fails its caller.
on:
workflow_call:
inputs:
version:
description: 'The version the dialect is for, X.Y.Z or X.Y.Z-pre.N'
type: string
required: true
ref:
description: 'The commit whose checker, types and lint config the dialect is checked with'
type: string
required: true
secrets:
TREK_DIALECT_TOKEN:
required: false
permissions:
contents: read
jobs:
dialect:
runs-on: ubuntu-24.04
timeout-minutes: 20
# A failed or timed-out run leaves no artifact, which the caller already
# handles; it must not fail the release around it.
continue-on-error: true
steps:
- uses: actions/checkout@v6
with:
ref: ${{ inputs.ref }}
persist-credentials: false
- uses: actions/setup-node@v6
with:
node-version-file: .nvmrc
cache: npm
cache-dependency-path: package-lock.json
# The only step that sees the token. The URL is fixed; the answer is
# bounded in time and size and goes to a file nothing runs yet.
- name: Fetch the dialect from the issuer
id: fetch
timeout-minutes: 3
env:
TREK_DIALECT_TOKEN: ${{ secrets.TREK_DIALECT_TOKEN }}
VERSION: ${{ inputs.version }}
run: |
set -uo pipefail
out="$RUNNER_TEMP/registration-dialect.ts"
if [ -z "${TREK_DIALECT_TOKEN:-}" ]; then
echo "::warning::TREK_DIALECT_TOKEN is not set; the issuer was not asked for a dialect for $VERSION."
exit 0
fi
status=$(curl --silent --show-error --proto '=https' --tlsv1.2 --max-time 30 --retry 2 --retry-delay 5 \
--max-filesize 262144 -H "Authorization: Bearer $TREK_DIALECT_TOKEN" -H 'Content-Type: application/json' \
--data "{\"version\":\"$VERSION\"}" -o "$out" -w '%{http_code}' https://places.liketrek.com/v1/dialects) \
|| status=000
if [ "$status" != 200 ] && [ "$status" != 201 ]; then
echo "::warning::The issuer handed out no registration dialect for $VERSION (HTTP $status)."
rm -f "$out"
exit 0
fi
if [ "$(wc -c < "$out")" -gt 262144 ]; then
echo "::warning::The issuer's registration dialect for $VERSION is larger than a dialect."
rm -f "$out"
exit 0
fi
echo "file=$out" >> "$GITHUB_OUTPUT"
# A module that computes for long is refused by the clock as well.
- name: Check the dialect
id: check
if: steps.fetch.outputs.file != ''
timeout-minutes: 2
env:
FILE: ${{ steps.fetch.outputs.file }}
VERSION: ${{ inputs.version }}
run: |
set -uo pipefail
if ! node scripts/ci/check-registration-dialect.mjs "$FILE" "$VERSION"; then
echo "::warning::The issuer's registration dialect for $VERSION failed its check."
exit 0
fi
echo "What changes against the dialect in the tree:"
git diff --no-index -- server/src/nest/service-identity/registration-dialect.ts "$FILE" || true
echo "ok=true" >> "$GITHUB_OUTPUT"
- name: Install the toolchain
if: steps.check.outputs.ok == 'true'
timeout-minutes: 8
run: |
npm ci --ignore-scripts --no-audit --no-fund
npm run build --workspace=shared
# The bump commit skips CI, so this is where the module meets the strict
# config (lint:strict lets a new file have no strict errors) and ESLint
# with --max-warnings 0 (lint:warnings starts a new file at none) before
# it lands on main.
- name: Type-check and lint the dialect
id: types
if: steps.check.outputs.ok == 'true'
timeout-minutes: 5
working-directory: server
env:
FILE: ${{ steps.fetch.outputs.file }}
VERSION: ${{ inputs.version }}
run: |
set -uo pipefail
cp "$FILE" src/nest/service-identity/registration-dialect.ts
# TypeScript 6 includes no @types by default and the dialect imports nothing
# from node, so checked on its own it would not know Buffer: name node here.
printf '%s\n' '{"extends": "./tsconfig.strict.json", "compilerOptions": {"types": ["node"]}, "include": [],' \
'"files": ["src/nest/service-identity/registration-dialect.ts"]}' > tsconfig.dialect-check.json
if ! npx tsc -p tsconfig.dialect-check.json --noEmit --pretty false; then
echo "::warning::The issuer's registration dialect for $VERSION does not type-check under the strict config."
exit 0
fi
if ! npx eslint --max-warnings 0 src/nest/service-identity/registration-dialect.ts; then
echo "::warning::The issuer's registration dialect for $VERSION does not pass ESLint."
exit 0
fi
echo "ok=true" >> "$GITHUB_OUTPUT"
- name: Hand the dialect on
if: steps.types.outputs.ok == 'true'
uses: actions/upload-artifact@v6
with:
name: registration-dialect
path: ${{ steps.fetch.outputs.file }}
if-no-files-found: error
retention-days: 1
+2 -2
View File
@@ -74,12 +74,12 @@ jobs:
load: true
tags: trek:scan
- uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- uses: docker/scout-action@e7e2a72bf1485d10a58f30696b6c513b261ce4fa # v1.29.0
- uses: docker/scout-action@221e7f4860634eeb1579e3bd7ca232e577bd1864 # v1.26.0
with:
command: cves
image: trek:scan
+3 -42
View File
@@ -1100,7 +1100,7 @@ jobs:
path: plugin-sdk/coverage/
- name: Scan
uses: SonarSource/sonarqube-scan-action@4d1dc0c91ee400dcf8a6dddf2f705ac36542e304 # v8.4.0
uses: SonarSource/sonarqube-scan-action@d209202bc7d53ff1cc128f7f907dac145c9d6ae9 # v8.3.0
env:
# SonarQube Cloud is the scan action's default host, so no
# SONAR_HOST_URL — the token is a sonarcloud.io token for the
@@ -1161,7 +1161,7 @@ jobs:
docker run -d --name trek-ci -p 127.0.0.1:3000:3000 \
--read-only --tmpfs /tmp:noexec,nosuid,size=128m \
--security-opt no-new-privileges:true \
--cap-drop ALL --cap-add CHOWN --cap-add SETUID --cap-add SETGID --cap-add KILL \
--cap-drop ALL --cap-add CHOWN --cap-add SETUID --cap-add SETGID \
-v "$RUNNER_TEMP/trek/data:/app/data" \
-v "$RUNNER_TEMP/trek/uploads:/app/uploads" \
-e ENCRYPTION_KEY="$(openssl rand -hex 32)" \
@@ -1194,17 +1194,6 @@ jobs:
curl -sf http://127.0.0.1:3000/ | grep -q '<div id="root">' \
|| { echo "::error::/ does not serve the client"; exit 1; }
# Same capabilities as the shipped compose file: dumb-init has to be able
# to pass the stop signal on to node, or docker stop ends in SIGKILL (137).
- name: Stop gracefully
run: |
set -euo pipefail
docker stop -t 30 trek-ci >/dev/null
code="$(docker inspect -f '{{.State.ExitCode}}' trek-ci)"
[ "$code" = "0" ] || { echo "::error::docker stop ended with exit code $code, not a graceful shutdown"; exit 1; }
docker logs trek-ci 2>&1 | grep 'SIGTERM received' >/dev/null \
|| { echo "::error::the server never saw SIGTERM"; exit 1; }
- name: Container log
if: always()
run: docker logs trek-ci 2>&1 || true
@@ -1263,39 +1252,11 @@ jobs:
--set 'extraEnv[0].name=SESSION_DURATION' \
--set 'extraEnv[0].value=7d' \
--set 'extraEnvFrom[0].secretRef.name=trek-smtp' \
--set 'extraVolumes[0].name=kitinerary-extractors' \
--set 'extraVolumes[0].configMap.name=trek-kitinerary-extractors' \
--set 'extraVolumeMounts[0].name=kitinerary-extractors' \
--set 'extraVolumeMounts[0].mountPath=/usr/local/share/kitinerary/extractors' \
--set 'extraVolumeMounts[0].readOnly=true' \
--set probes.liveness=null \
> "$out/variants.yaml"
helm template trek charts/trek \
--set httpRoute.enabled=true \
--set 'httpRoute.parentRefs[0].name=gateway' \
> "$out/http-route.yaml"
helm template trek charts/trek \
--set podSecurityContext.runAsUser=1000 \
--set podSecurityContext.runAsGroup=1000 \
--set podSecurityContext.runAsNonRoot=true \
--set podSecurityContext.seccompProfile.type=RuntimeDefault \
--set securityContext.allowPrivilegeEscalation=false \
--set 'securityContext.capabilities.drop[0]=ALL' \
> "$out/non-root.yaml"
helm template trek charts/trek \
--set ingress.enabled=true \
--set fullnameOverride=custom-trek \
--set service.port=8080 \
--set-json 'httpRoute={"enabled":true,"parentRefs":[{"name":"gateway","namespace":"gateway-system","sectionName":"https"}],"hostnames":["trek.example.com"],"paths":["/","/api"],"annotations":{"example.com/note":"custom route"},"timeouts":{"request":"0s","backendRequest":"0s"}}' \
> "$out/http-route-variants.yaml"
- name: Check the manifests against the Kubernetes schemas
# The pinned Gateway API schema adds HTTPRoute validation without skipping unknown resources.
run: >-
kubeconform -strict -summary
-schema-location default
-schema-location 'https://raw.githubusercontent.com/datreeio/CRDs-catalog/041baf4c1d3740ca21461cab765e7432ecd073ef/{{.Group}}/{{.ResourceKind}}_{{.ResourceAPIVersion}}.json'
"$RUNNER_TEMP"/rendered/*.yaml
run: kubeconform -strict -summary "$RUNNER_TEMP"/rendered/*.yaml
- name: Check every compose file
run: |
+2 -2
View File
@@ -22,6 +22,7 @@ WORKDIR /app
COPY package.json package-lock.json ./
COPY shared/package.json ./shared/
COPY client/package.json ./client/
COPY client/scripts/patch-maplibre.mjs ./client/scripts/
RUN npm ci --workspace=client
COPY --from=shared-builder /app/shared/dist ./shared/dist
COPY client/ ./client/
@@ -91,8 +92,7 @@ ENV KITINERARY_EXTRACTOR_PATH=/usr/local/bin/kitinerary-extractor
COPY --chown=node:node --from=server-builder /app/server/dist ./server/dist
# Runtime data assets read from server/assets at runtime: airports.json (flight
# transport search) and atlas/*.geojson.gz + atlas/admin1/*.geojson.gz (Atlas
# country/region map). The build
# transport search) and atlas/*.geojson.gz (Atlas country/region map). The build
# only emits dist, so these must be copied explicitly or the features silently
# degrade to empty in the image.
COPY --chown=node:node --from=server-builder /app/server/assets ./server/assets
+2 -54
View File
@@ -5,9 +5,8 @@ TREK bundles and uses third-party data that requires attribution.
## geoBoundaries — country & sub-national boundaries
The Atlas map's administrative boundaries (admin-0 countries and admin-1
provinces/counties), shipped at `server/assets/atlas/admin0.geojson.gz`,
`server/assets/atlas/admin1.geojson.gz` and `server/assets/atlas/admin1/<ISO_A2>.geojson.gz`
(the same regions, one file per country) and generated by
provinces/counties), shipped at `server/assets/atlas/admin0.geojson.gz` and
`server/assets/atlas/admin1.geojson.gz` and generated by
`server/scripts/build-atlas-geo.mjs`, are derived from **geoBoundaries**.
> Runfola, D. et al. (2020) geoBoundaries: A global database of political
@@ -54,54 +53,3 @@ own switch in the admin panel. https://cloud.google.com/maps-platform/terms
`server/assets/airports.json` is built from **OurAirports**
(https://ourairports.com/data/), released into the public domain.
## Typefaces: Studio books and the app
Every typeface a book can be set in, and the app's own (Poppins, MuseoModerno,
Geist), is self-hosted from the **@fontsource** npm packages, which redistribute
the families published on Google Fonts split into subsets by script. The catalogue of book faces is
`shared/src/book/book-fonts.data.ts`. Each package carries its licence and the
copyright notice of the family's authors in its `LICENSE` file.
Both licences allow a font to be bundled with software and embedded in a
document made with it, so a printed or exported book may carry the faces it is
set in. No family is renamed, redrawn or sold on its own. SIL OFL 1.1: https://openfontlicense.org. Apache 2.0:
https://www.apache.org/licenses/LICENSE-2.0
| Family | Package | Licence |
|---|---|---|
| Abril Fatface | `@fontsource/abril-fatface` | SIL Open Font License 1.1 |
| Alfa Slab One | `@fontsource/alfa-slab-one` | SIL Open Font License 1.1 |
| Allura | `@fontsource/allura` | SIL Open Font License 1.1 |
| Anton | `@fontsource/anton` | SIL Open Font License 1.1 |
| Archivo Black | `@fontsource/archivo-black` | SIL Open Font License 1.1 |
| Bebas Neue | `@fontsource/bebas-neue` | SIL Open Font License 1.1 |
| Big Shoulders Display | `@fontsource/big-shoulders-display` | SIL Open Font License 1.1 |
| Bodoni Moda | `@fontsource/bodoni-moda` | SIL Open Font License 1.1 |
| Bricolage Grotesque | `@fontsource/bricolage-grotesque` | SIL Open Font License 1.1 |
| Caveat | `@fontsource/caveat` | SIL Open Font License 1.1 |
| Cormorant Garamond | `@fontsource/cormorant-garamond` | SIL Open Font License 1.1 |
| Dancing Script | `@fontsource/dancing-script` | SIL Open Font License 1.1 |
| DM Mono | `@fontsource/dm-mono` | SIL Open Font License 1.1 |
| DM Serif Display | `@fontsource/dm-serif-display` | SIL Open Font License 1.1 |
| EB Garamond | `@fontsource/eb-garamond` | SIL Open Font License 1.1 |
| Fraunces | `@fontsource/fraunces` | SIL Open Font License 1.1 |
| Great Vibes | `@fontsource/great-vibes` | SIL Open Font License 1.1 |
| Homemade Apple | `@fontsource/homemade-apple` | Apache License 2.0 |
| Inter | `@fontsource/inter` | SIL Open Font License 1.1 |
| Kaushan Script | `@fontsource/kaushan-script` | SIL Open Font License 1.1 |
| League Gothic | `@fontsource/league-gothic` | SIL Open Font License 1.1 |
| Lora | `@fontsource/lora` | SIL Open Font License 1.1 |
| Montserrat | `@fontsource/montserrat` | SIL Open Font License 1.1 |
| MuseoModerno | `@fontsource/museomoderno` | SIL Open Font License 1.1 |
| Oswald | `@fontsource/oswald` | SIL Open Font License 1.1 |
| Pacifico | `@fontsource/pacifico` | SIL Open Font License 1.1 |
| Parisienne | `@fontsource/parisienne` | SIL Open Font License 1.1 |
| Permanent Marker | `@fontsource/permanent-marker` | Apache License 2.0 |
| Playfair Display | `@fontsource/playfair-display` | SIL Open Font License 1.1 |
| Poppins | `@fontsource/poppins` | SIL Open Font License 1.1 |
| Sacramento | `@fontsource/sacramento` | SIL Open Font License 1.1 |
| Satisfy | `@fontsource/satisfy` | Apache License 2.0 |
| Shrikhand | `@fontsource/shrikhand` | SIL Open Font License 1.1 |
| Yellowtail | `@fontsource/yellowtail` | Apache License 2.0 |
| Geist Sans | `@fontsource/geist-sans` | SIL Open Font License 1.1 |
+95 -153
View File
@@ -6,9 +6,14 @@
<img src="docs/logo-trek-dark.svg" alt="TREK" height="96" />
</picture>
### Every part of your trip. One app you own.
<br />
<picture>
<source media="(prefers-color-scheme: dark)" srcset="docs/subtitle-light.png" />
<source media="(prefers-color-scheme: light)" srcset="docs/subtitle-dark.png" />
<img src="docs/subtitle-dark.png" alt="your trip. your plan." height="28" />
</picture>
TREK has grown from a travel planner into a whole ecosystem for trips: plan together in real time on a live map, build hikes, tours and road trips, keep bookings and shared costs in order, pack, write the journal, lay out the photo book and hand the busy work to the built-in AI. Self-hosted, on the web, on your phone and offline.
A self-hosted, real-time collaborative travel planner — with maps, budgets, packing lists, a journal, and AI built in.
<br />
@@ -27,16 +32,11 @@ TREK has grown from a travel planner into a whole ecosystem for trips: plan toge
<a href="https://ko-fi.com/mauriceboe"><img alt="Ko-fi" src="https://img.shields.io/badge/Ko--fi-support-FF5E5B?style=for-the-badge" /></a>
&nbsp;
<a href="https://www.buymeacoffee.com/mauriceboe"><img alt="BMAC" src="https://img.shields.io/badge/BMAC-support-FFDD00?style=for-the-badge" /></a>
&nbsp;
<a href="LICENSE"><img alt="License" src="https://img.shields.io/badge/License-AGPL_v3-6B7280?style=for-the-badge" /></a>
&nbsp;
<a href="https://github.com/liketrek/TREK/releases"><img alt="Latest Release" src="https://img.shields.io/github/v/release/liketrek/trek?include_prereleases&style=for-the-badge&label=Release&color=6B7280" /></a>
&nbsp;
<a href="https://hub.docker.com/r/mauriceboe/trek"><img alt="Docker Pulls" src="https://img.shields.io/docker/pulls/mauriceboe/trek?style=for-the-badge&label=Docker%20pulls&color=6B7280" /></a>
&nbsp;
<a href="https://github.com/liketrek/TREK"><img alt="Stars" src="https://img.shields.io/github/stars/liketrek/trek?style=for-the-badge&label=Stars&color=6B7280" /></a>
<sub>PUBLIC SERVICES PROUDLY SPONSORED BY</sub><br /><a href="https://cloud.dataforest.net/en?mtm_campaign=trek"><picture><source media="(prefers-color-scheme: dark)" srcset="docs/sponsors/dataforest-light.svg" /><source media="(prefers-color-scheme: light)" srcset="docs/sponsors/dataforest-dark.svg" /><img src="docs/sponsors/dataforest-dark.svg" alt="dataforest cloud" height="30" /></picture></a>
<br />
<a href="LICENSE"><img alt="License" src="https://img.shields.io/badge/license-AGPL_v3-6B7280?style=flat-square" /></a>
<a href="https://github.com/liketrek/TREK/releases"><img alt="Latest Release" src="https://img.shields.io/github/v/release/liketrek/trek?include_prereleases&style=flat-square&color=6B7280" /></a>
<a href="https://hub.docker.com/r/mauriceboe/trek"><img alt="Docker Pulls" src="https://img.shields.io/docker/pulls/mauriceboe/trek?style=flat-square&color=6B7280" /></a>
<a href="https://github.com/liketrek/TREK"><img alt="Stars" src="https://img.shields.io/github/stars/liketrek/trek?style=flat-square&color=6B7280" /></a>
</div>
---
@@ -62,7 +62,7 @@ TREK has grown from a travel planner into a whole ecosystem for trips: plan toge
<br />
Most of what follows is an addon an admin switches on or off. Lists, Costs, Documents, Collab, Tours, Vacay and Atlas ship on; Journey, Collections, MCP, the AI assistant, AI Parsing, Road trip, Dawarich and AirTrail ship off and are marked below.
Most of what follows is an addon an admin switches on or off. Lists, Costs, Documents, Collab, Vacay and Atlas ship on; Journey, Collections, MCP, AI Parsing and AirTrail ship off and are marked below.
<table>
<tr>
@@ -70,110 +70,76 @@ Most of what follows is an addon an admin switches on or off. Lists, Costs, Docu
#### 🧭 Planning
- **Day plans**: drag places, notes and bookings between days with undo, or drop a map marker straight onto a day
- **Maps**: Leaflet, Mapbox GL or MapLibre GL (OpenFreeMap, no token) with clustering, photo markers, route lines and a whole-trip route overview
- **Places**: search the TREK Places index and OpenStreetMap with no key, or Google Places and Amap with one. Descriptions, hours and photos from Wikipedia, Wikidata and Wikimedia Commons, plus a rating and a custom image per place
- **Explore**: nearby places by category for the map view, from the TREK Places index with Overpass as fallback
- **Routes**: auto-sort a day (nearest neighbour then 2-opt, locked stops and hotels stay put), a travel mode per leg over OSRM or Valhalla, then open the day in Google Maps or CoMaps and a place in seven navigation apps
- **Public transport**: door-to-door itineraries over Transitous, or Google where Transitous has no data
- **Import and export**: Google Maps and Naver Maps lists, Google Maps directions links, GPX, KML and KMZ in; GPX and an ICS feed per trip or across all trips out
- **Weather**: 16-day forecast from Open-Meteo, no key, and the archive for dates beyond it
- **Trip dates**: move a trip and its days re-date themselves, with the bookings along or re-anchored. Trips also copy and archive
- **Road trip** (off by default): drives with stops, day windows and arrival times that recompute, route alternatives, hazards and EV charging with range
- **Day plans**: drag places between days and reorder inside a day, with undo. Notes and bookings drag the same way, and a map marker drops straight onto a day
- **Maps**: Leaflet, Mapbox GL or MapLibre GL (OpenFreeMap, no token), with clustering, photo markers and route lines. 3D buildings and terrain are Mapbox only
- **Place search**: Google Places when a key is set (photos, ratings, opening hours), otherwise OpenStreetMap with no key
- **Place enrichment**: descriptions, facts, hours and photo candidates from OpenStreetMap, Wikipedia, Wikidata and Wikimedia Commons
- **POI explore**: pull OpenStreetMap POIs by category for the current viewport over Overpass
- **Import**: shared Google Maps and Naver Maps lists, plus GPX, KML and KMZ files
- **Export**: GPX of a trip's places and tracks, and an ICS feed per trip or across all of them
- **Routes**: auto-sort a day (nearest neighbour then 2-opt, locked stops and hotel anchors stay put), driving, walking or cycling profiles over OSRM, then open it in Google Maps or CoMaps
- **Public transport**: door-to-door itineraries over Transitous
- **Weather**: 16-day forecast from Open-Meteo, no key. Dates outside that window read the archive for the same date instead
- **Day notes**: markdown body with an icon and a colour, reordered by drag and drop or moved to another day
- **Trip dates**: move a trip and the days re-date themselves, either dragging the bookings along or re-anchoring them. Trips also copy and archive
</td>
<td width="50%" valign="top">
#### 🥾 Tours
- **Seven tour types**: hike, city walk, run, bike (touring, road, gravel, MTB), paddle (kayak, canoe, SUP), horse ride, and ski and winter (alpine, cross-country, touring, snowshoe, winter hike)
- **Routing**: legs routed over Valhalla with shaping points, hand-drawn legs, route variants, round hikes of a wished length and trip places as stops, all with undo
- **Hikes**: routed within a maximum SAC grade (T1 to T6), the trail grades along the way, and via ferratas on the map
- **Terrain**: elevation profile, ascent and descent, and a 3D terrain map with grade colouring and a flyover, no token needed. Topo, hiking, cycling and satellite bases with sea marks and ski pistes as overlays
- **Multi-day tours**: one route cut into stages and spread across the trip's days
- **Along the way**: viewpoints, huts, water, food and more within a corridor of the route, inserted as a stop with the detour it adds
- **Ski, paddle and city walks**: a piste router by difficulty and lift type; a water router with portages, locks and a wind forecast; city walks with stays, opening hours and transit between stops
- **Plan against reality**: link a recorded GPX to a planned day and compare distance, ascent and time. Import trails from a wanderer instance, save tours to a collection and take them into another trip
</td>
</tr>
<tr>
<td width="50%" valign="top">
#### 🧳 Bookings and money
- **Reservations**: 18 booking types with status, confirmation code, travellers and attached files
- **Flights and trains**: several legs with stopovers and endpoint timezones, against 4,045 bundled airports so local times resolve without a key
- **Accommodation**: a stay spans a range of nights with a check-in window
- **Booking import**: EML, PDF, PKPass, HTML, TXT and photos of confirmations, through [KItinerary](https://invent.kde.org/pim/kitinerary) or an AI model, with a review step before anything is saved. Prices become linked costs
- **AirTrail** (off by default): import flights from a self-hosted AirTrail instance and keep them in sync
- **Costs**: split in integer cents with equal or custom shares, several payers, settle-up suggestions, a per-person summary, a settlement log and CSV export. A receipt photo can fill in an expense
- **Currencies**: one per expense with the rate frozen at entry, from Frankfurter with no key
- **Packing lists and to-dos**: categories, templates, assignees and visibility tiers; bags with weights as an admin switch; to-dos with due dates, priorities and reminders
- **Files**: attach to a trip, place, day or booking, 50 MB each and 500 MB for video, with trash and restore
- **PDF export**: cover, place photos, day notes, bookings and costs, with an optional page break per day
- **Reservations**: 16 booking types with status, confirmation code, travellers and attached files
- **Flights and trains**: several legs with stopovers, per-leg times and endpoint timezones, against 4,045 bundled airports so local times resolve without a key
- **Accommodation**: a stay spans a range of days with a check-in window, and shows on every night it covers
- **Booking import**: EML, PDF, PKPass, HTML and TXT confirmations through [KItinerary](https://invent.kde.org/pim/kitinerary). Needs the `kitinerary-extractor` binary, which ships in the Docker image
- **AirTrail** (off by default): link a self-hosted AirTrail instance to import flights as reservations and keep them in sync
- **Costs**: split expenses in integer cents with equal or custom shares, several payers per expense, settle-up suggestions, a settlement log, and CSV export
- **Currencies**: a currency per expense with the rate frozen at entry. Rates come from Frankfurter, no key
- **Packing lists**: categories, admin-managed templates, assignees, three visibility tiers, and a packed-of-total line. Bags with weight roll-up are a separate admin switch, off by default
- **To-dos**: assignee, due date, priority, and a reminder before one falls due
- **Files**: attach to a trip, place, day entry or reservation. 50 MB each, 500 MB for video, with trash and restore
- **PDF export**: cover page, place photos, day notes, bookings and costs, with an optional page break per day
</td>
</tr>
<tr>
<td width="50%" valign="top">
#### 👥 Collaboration
- **Real-time sync** (WebSocket): edits land live for everyone who has the trip open
- **Members**: add by email or username, hand over ownership, or add guests who have no login at all
- **Permissions**: an admin maps each of 17 trip actions to admin, trip owner, trip member or everybody
- **Invites**: one reusable link per trip with an optional expiry, and admin signup invites that drop a new account straight into a trip
- **Public share**: a read-only trip page without an account, with switches for map, bookings, packing, costs and chat
- **Collab**: group chat with replies, reactions and link previews, shared notes, links, polls and a What's Next list, each one its own switch
- **Notifications**: an inbox grouped by day with answers right from the notification, and a per-user matrix of events against in-app, email, webhook, ntfy, Web Push and plugin channels
- **Real-time sync** (WebSocket): edits land live for everyone who has that trip open
- **Members**: add by email or username, hand ownership to someone else, or add guests who have no login at all
- **Permissions**: an admin maps each of 16 trip actions to admin, trip owner, trip member or everybody
- **Invite links**: one reusable link per trip with an optional expiry. Admins can also issue signup invites with a use limit that drop the new account straight into a trip
- **Public share**: a read-only trip page anyone can open without an account
- **Collab**: group chat with replies, reactions and link previews, shared notes with attachments, polls, and a What's Next list of upcoming activities. Each of the four switches on its own
</td>
</tr>
<tr>
<td width="50%" valign="top">
#### 📔 Journal, Atlas and Vacay
#### 📔 Journey and TREK Studio
- **Journey** (off by default): dated entries with story, mood, weather and tags, photos and video from uploads, [Immich](https://immich.app) or Synology Photos, map views, co-authors, drafts and a public share link
- **Linked journal**: a trip can show one journal as its own, with the members picked on the trip as viewers or editors
- **Recorded days** (with Dawarich): keep a day's recorded route and turn its stays into entries
- **TREK Studio**: turn a journal into a printable photo book. 34 print-licensed typefaces, 17 themes, 108 designed pages in twelve styles, 160 recolourable stickers, text effects, layers and a Travel tab with route maps, country stamps and trip figures
- **Auto layout**: lays the whole book on the designed pages in the style of its theme, every photo in a frame, or rebuilds one spread
- **Print and share**: PDF export as pages or spreads with crop marks, several people editing at once, and design files to move a spread into another book
</td>
<td width="50%" valign="top">
#### 🗺️ Atlas, Vacay and Collections
- **Atlas**: countries and regions visited on [geoBoundaries](https://www.geoboundaries.org/) outlines, a bucket list, travel stats, and a tour activities tab with planned against recorded totals
- **Vacay**: leave calendar with half days, public holidays from date.nager.at, school holidays for 16 European countries, company holidays, carry-over between years, and joint or read-only sharing
- **Collections** (off by default): a place library outside any trip with lists, labels, ratings, a status per place, cover images, GPX and file export, copy into a trip, and sharing by invite
- **Dawarich** (off by default): link a Dawarich instance for recorded tracks, suggested stays and countries for the Atlas
</td>
</tr>
<tr>
<td width="50%" valign="top">
#### 🤖 AI assistant and MCP
- **AI assistant** (off by default): a chat that plans with you in a trip, across all your trips, in a journal and in Studio, and works with tours, costs, bookings, packing, Vacay and the Atlas
- **Your own model**: an API key for Anthropic, OpenAI, Gemini or OpenRouter, a ChatGPT plan, or any OpenAI-compatible endpoint, set for the instance or per user
- **You stay in charge**: reads run at once, every change waits as a card to apply or discard and can be undone for 15 minutes. Nothing leaves before you agree to the provider, and a file's text only with your consent
- **Attachments**: drop a booking confirmation, a GPX file or a photo into the chat, and the assistant offers what to do with it
- **MCP server** (off by default): OAuth 2.1 with mandatory PKCE and dynamic client registration, 410 tools, 30 resources and 4 prompts, 35 scopes in 17 groups ticked on the consent screen, and every call in the audit log
- **Addon-aware**: twelve addon gates decide which tools a session sees, and flipping one re-registers live sessions
- **Journey** (off by default): dated entries with story, mood, weather and tags, photos and video from uploads or a linked [Immich](https://immich.app) or Synology Photos library, map views, co-authors, and a public share link
- **Atlas**: mark countries and sub-national regions visited on [geoBoundaries](https://www.geoboundaries.org/) outlines, plus a bucket list, travel stats and a consecutive-years-travelled count
- **Vacay**: leave calendar with half days, public holidays from date.nager.at, school holiday overlays for 16 European countries, carry-over between years, and joint or read-only sharing of a plan
- **Collections** (off by default): a place library outside any trip, with labels, ratings, a custom image per place, copy-into-a-trip, and sharing by invite
</td>
<td width="50%" valign="top">
#### 🧩 Plugins
- **Third-party plugins**: install from the TREK registry or sideload a zip, with their own pages under `/plugins/<id>`
- **Sandboxed**: one child process per plugin, 66 grantable permissions, an admin-edited outbound host allowlist, memory and call caps
- **Trust**: registry downloads are pinned by sha256 and checked against the author's minisign key, a sideloaded zip is marked unverified, and `TREK_PLUGINS_ENABLED=false` turns the whole system off
- **Extension points**: map layers, place details, day schedules, PDF sections, Atlas layers, journal rows, trip warnings, search and photo providers, route profiles, notification channels and MCP tools
- **SDK**: [`trek-plugin-sdk`](https://www.npmjs.com/package/trek-plugin-sdk) with a CLI to create, validate, run against real data, sign and publish
- **Third-party plugins**: install from the TREK registry or sideload a zip, switch on per instance, with their own pages under `/plugins/<id>`
- **Sandboxed**: one child process per plugin, 63 grantable permissions, an admin-edited outbound host allowlist, memory and RPC caps, and daily caps on AI and notification calls
- **Trust**: registry downloads are pinned by sha256 and checked against the author's minisign key. A sideloaded zip is marked unverified, and `TREK_PLUGINS_ENABLED=false` turns the whole system off
- **Extension points**: map markers and layers, place details, day schedules, PDF sections, Atlas layers, journal rows, trip warnings, calendar sources, route profiles and notification channels. Plugin pages run in an opaque-origin iframe
- **SDK**: [`trek-plugin-sdk`](https://www.npmjs.com/package/trek-plugin-sdk) on npm, with a manifest validator, a mock host, and a dev-link mode that runs a local build against real data
#### 🤖 AI and MCP
- **MCP server** (off by default): OAuth 2.1 with mandatory PKCE and dynamic client registration. 199 tools, 30 resources, 4 prompts
- **Scopes**: 29 scopes in 14 groups, each one tickable on the consent screen. Tokens are bound to the `/mcp` resource, and every tool call lands in the audit log
- **Reach**: create trips and days, edit places, packing lists, to-dos, costs, reservations, collections and journeys, mark countries visited, all inside the scopes the token holds
- **Prompts**: `trip-summary`, plus `packing-list` and `budget-overview` when those addons are on
- **Addon-aware**: seven addon gates decide which tools and resources a session sees. Flipping one drops live sessions so the surface re-registers
- **Booking extraction** (off by default): read a confirmation with a local Ollama server, any OpenAI-compatible endpoint, or Anthropic, configured instance-wide or per user
</td>
</tr>
@@ -182,26 +148,30 @@ Most of what follows is an addon an admin switches on or off. Lists, Costs, Docu
#### 📱 Mobile and offline
- **Apps**: TREK Travel Planner for iPhone, iPad and Android connects to any TREK server, and the browser installs TREK as a standalone app with a themed status bar too
- **Phone layouts**: their own screens for every part of the app, with a bottom bar and dashboard widgets the user orders
- **Offline reads**: the app shell is precached and trips, places, tours, files and nearby places live in a per-user IndexedDB store, so a trip opens with no network
- **Offline writes**: changes queue and replay with an `X-Idempotency-Key`, and a stale edit waits for a keep-mine or keep-theirs choice
- **Offline maps**: raster and vector tiles of a trip downloaded ahead, and a switch that takes the whole app offline on purpose
- **Installable**: iOS and Android straight from the browser, no App Store. Runs standalone without browser chrome, with the status bar tinted per theme
- **Phone layouts**: a separate shell below 768px with its own tokens, a bottom bar and safe-area insets. Which items sit in that bar is up to the user
- **Offline reads**: the app shell and every route chunk are precached, and trips, places and file blobs live in a per-user IndexedDB store, so a trip opens with no network
- **Offline writes**: mutations queue and replay with an `X-Idempotency-Key`, so a reconnect cannot double-apply. A stale edit is parked for a keep-mine or keep-theirs choice
- **Offline maps**: pre-download a trip's raster tiles, and clear them again
- **API responses are never cached** by the service worker. They vary per session, so they always come from the network
</td>
<td width="50%" valign="top">
#### ⚙️ Admin, accounts and security
- **Sign-in**: password, OIDC and passkeys each switch on and off on their own, `OIDC_ONLY` leaves SSO as the only way in, and an OIDC claim can grant admin
- **SSO and 2FA**: one OIDC provider by discovery with PKCE (Authentik, Keycloak, Google and the like), TOTP with backup codes that an admin can require, and passkeys that count as the second factor
- **Sessions**: every sign-in is a session you can see and end, one by one or all others at once
- **Hardening**: rate limits on sign-in, reset and 2FA, a password policy, secrets encrypted at rest and masked on read, and an SSRF guard on every URL you configure
- **Admin panel**: users and the permission matrix, defaults for new users, categories and templates, addons, plugins, storage, notifications, MCP tokens, backups, audit log and releases
- **Backups and storage**: scheduled backups with retention that restore their own secrets, and local or S3-compatible storage per content category with replicas
- **Appearance**: light, dark or follow the OS, seven colour schemes plus a custom accent, transparency, density, reduced motion and text size
- **28 languages**: en, de, es, fr, it, nl, hu, ru, zh, zh-TW, pl, cs, ar (right to left), he (right to left), br, id, tr, ja, ko, uk, gr, sv, vi, ca, az, et, sk, th
- **Help center**: a ? in the top bar with 381 step-by-step guides with screenshots, searched together with the wiki that ships in the image
- **Login methods**: password login, password registration, OIDC login, OIDC registration and passkey login each switch on and off on their own. `OIDC_ONLY` leaves SSO as the only way in
- **SSO (OIDC)**: one provider by discovery, with PKCE and `id_token` verification (Authentik, Keycloak, Google and the like)
- **2FA**: TOTP with ten single-use backup codes, and an admin can require it instance-wide
- **Passkeys**: WebAuthn login by fingerprint, face, PIN or security key, off until an admin enables it. A passkey also satisfies the 2FA requirement
- **Hardening**: per-IP limits on login, password reset and 2FA attempts, a password policy, secrets encrypted at rest and masked on read, and an SSRF guard on every URL you configure
- **Admin panel**: users and invites, the permission matrix, packing templates, categories, addons, plugins, API keys, MCP tokens and OAuth sessions, backups, storage, audit log, and GitHub releases
- **Backups**: manual or scheduled hourly, daily, weekly or monthly, with retention in days. The zip carries the at-rest key, so a restore can decrypt its own secrets
- **Storage**: pluggable storage backends per content category — keep everything on local disk, or add S3-compatible backends and replicate any category to them, configured entirely from the admin panel
- **Notifications**: a per-user matrix of events against in-app, email (SMTP), webhook, ntfy and Web Push (on every device you turn it on for, even with TREK closed), plus any channel a plugin registers
- **Appearance**: light, dark or follow the OS, seven colour schemes plus a custom accent, transparency, compact density, reduce motion, and text size per tier
- **27 languages**: en, de, es, fr, it, nl, hu, ru, zh, zh-TW, pl, cs, ar (right to left), br, id, tr, ja, ko, uk, gr, sv, vi, ca, az, et, sk, th
- **In-app help**: the wiki ships inside the image and is served from disk at `/help`, so the docs match the version you are running
</td>
</tr>
@@ -214,39 +184,7 @@ Most of what follows is an addon an admin switches on or off. Lists, Costs, Docu
<div align="center">
<a href="docs/screenshots/showcase-desktop-1.webp"><img src="docs/screenshots/showcase-desktop-1.webp" alt="Atlas · dashboard · trip planner" width="100%" /></a>
<a href="docs/screenshots/showcase-mobile.webp"><img src="docs/screenshots/showcase-mobile.webp" alt="Mobile PWA · dashboard, day plan, map, costs" width="100%" /></a>
</div>
<br />
## Powered by [dataforest cloud](https://cloud.dataforest.net/en?mtm_campaign=trek)
<div align="center">
<br />
<a href="https://cloud.dataforest.net/en?mtm_campaign=trek">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="docs/sponsors/dataforest-light.svg" />
<source media="(prefers-color-scheme: light)" srcset="docs/sponsors/dataforest-dark.svg" />
<img src="docs/sponsors/dataforest-dark.svg" alt="dataforest cloud" height="60" />
</picture>
</a>
<br />
<br />
**Every TREK installation in the world searches through servers that [dataforest cloud](https://cloud.dataforest.net/en?mtm_campaign=trek) sponsors.**
The place index with over 88 million places, the address search in every TREK language and the planet-wide routing all run on [dataforest cloud](https://cloud.dataforest.net/en?mtm_campaign=trek) in Frankfurt, Germany, free of charge for everyone who self-hosts TREK. Without this sponsorship, these services would not exist in this form.
Thank you, [dataforest](https://cloud.dataforest.net/en?mtm_campaign=trek), for backing open source this generously. 💚
<br />
<a href="https://cloud.dataforest.net/en?mtm_campaign=trek"><img alt="Visit dataforest cloud" src="https://img.shields.io/badge/Visit-dataforest_cloud-02C382?style=for-the-badge" /></a>
Looking for a server for your own TREK? [dataforest cloud](https://cloud.dataforest.net/en?mtm_campaign=trek) is where TREK's own services live.
<a href="docs/screenshots/showcase-desktop-2.webp"><img src="docs/screenshots/showcase-desktop-2.webp" alt="Collections · journey journal · costs" width="100%" /></a>
</div>
<br />
@@ -266,18 +204,12 @@ the details.
## Get started in 30 seconds
```bash
ENCRYPTION_KEY=$(openssl rand -hex 32) docker run -d --name trek -p 3000:3000 \
ENCRYPTION_KEY=$(openssl rand -hex 32) docker run -d -p 3000:3000 \
-e ENCRYPTION_KEY=$ENCRYPTION_KEY \
-e COOKIE_SECURE=false \
-v ./data:/app/data -v ./uploads:/app/uploads mauriceboe/trek
```
Open `http://localhost:3000`, or `http://<server-ip>:3000` from another machine. On first boot TREK seeds an admin account — if you set `ADMIN_EMAIL`/`ADMIN_PASSWORD` those are used, otherwise the credentials are printed to the container log (`docker logs trek`).
`COOKIE_SECURE=false` is only for trying TREK over plain HTTP: without it the browser drops the session cookie and the login does not stick. Once TREK sits behind HTTPS (see [Reverse Proxy](#reverse-proxy)), remove it.
> [!TIP]
> **No server yet?** TREK's own public services run on [**dataforest cloud**](https://cloud.dataforest.net/en?mtm_campaign=trek), our infrastructure sponsor. A small seed is plenty for a TREK instance and is ready within a minute.
Open `http://localhost:3000`. On first boot TREK seeds an admin account — if you set `ADMIN_EMAIL`/`ADMIN_PASSWORD` those are used, otherwise the credentials are printed to the container log (`docker logs trek`).
<div align="center">
@@ -335,7 +267,7 @@ See [`charts/README.md`](https://github.com/liketrek/TREK/blob/main/charts/READM
<h2 id="install-as-app-pwa">Install as App (PWA)</h2>
TREK works as a Progressive Web App, and the TREK Travel Planner app for iPhone, iPad and Android connects to the same server ([Native Apps](https://github.com/liketrek/TREK/wiki/Native-Apps)). To install the web app instead:
TREK works as a Progressive Web App — no App Store needed.
1. Open TREK in the browser (HTTPS required)
2. **iOS**: Share ▸ *Add to Home Screen*
@@ -433,6 +365,16 @@ Caddy handles TLS and WebSockets automatically.
Every variable, its default and what it does: see
[Environment Variables](https://github.com/liketrek/TREK/wiki/Environment-Variables).
## Star History
<a href="https://www.star-history.com/?repos=liketrek%2FTREK&type=date&legend=bottom-right">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=liketrek/TREK&type=date&theme=dark&legend=bottom-right&sealed_token=tW9agibKugOyPk0yPURgSdnizM7K72zbJENGD9iyk24ZcwYHtCUn-hk9jh1gdacO0O7xJ3Io7LaNrwYD3mChzcpzZIqDC8_HevYG1qnT0XNTmN6Ez4UZrQ" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=liketrek/TREK&type=date&legend=bottom-right&sealed_token=tW9agibKugOyPk0yPURgSdnizM7K72zbJENGD9iyk24ZcwYHtCUn-hk9jh1gdacO0O7xJ3Io7LaNrwYD3mChzcpzZIqDC8_HevYG1qnT0XNTmN6Ez4UZrQ" />
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=liketrek/TREK&type=date&legend=bottom-right&sealed_token=tW9agibKugOyPk0yPURgSdnizM7K72zbJENGD9iyk24ZcwYHtCUn-hk9jh1gdacO0O7xJ3Io7LaNrwYD3mChzcpzZIqDC8_HevYG1qnT0XNTmN6Ez4UZrQ" />
</picture>
</a>
## Data sources
The Atlas map's country and sub-national (province/county) boundaries come from
+3 -6
View File
@@ -5,10 +5,9 @@ This is a minimal Helm chart for deploying the TREK app.
## Features
- Deploys the TREK container
- Exposes port 3000 via Service
- Optional persistent storage for `/app/data` and `/app/uploads`, plus extra volumes and mounts
- Optional persistent storage for `/app/data` and `/app/uploads`
- Configurable environment variables and secrets
- Optional generic Ingress support
- Optional Gateway API HTTPRoute support
- Configurable health checks (liveness/readiness probes) on `/api/health`
## Helm Repository
@@ -28,7 +27,7 @@ helm install trek trek/trek
Or install directly from the local chart:
```sh
helm install trek ./charts/trek \
helm install trek ./chart \
--set ingress.enabled=true \
--set ingress.hosts[0].host=yourdomain.com
```
@@ -48,7 +47,7 @@ See `values.yaml` for more options.
- `JWT_SECRET` is managed entirely by the server — auto-generated into the data PVC on first start and rotatable via the admin panel (Settings → Danger Zone). No Helm configuration needed.
- `ENCRYPTION_KEY` encrypts stored secrets (API keys, MFA, SMTP, OIDC) at rest. Recommended: set via `secretEnv.ENCRYPTION_KEY` or `existingSecret`. If left empty, the server falls back automatically: existing installs use `data/.jwt_secret` (no action needed on upgrade); fresh installs auto-generate a key persisted to the data PVC.
- Liveness and readiness probes are configurable under `probes`. Overrides are merged over the defaults, so `--set probes.liveness.initialDelaySeconds=60` keeps the `httpGet` handler. When switching to `exec` or `tcpSocket`, clear `httpGet` in the same override. Set `probes.liveness: null` to remove a probe.
- If exposing TREK through an Ingress or HTTPRoute, set `env.ALLOWED_ORIGINS` and `env.APP_URL` to the public URL. The chart does not sync these automatically.
- If using ingress, you must manually keep `env.ALLOWED_ORIGINS` and `ingress.hosts` in sync to ensure CORS works correctly. The chart does not sync these automatically.
- Set `env.ALLOW_INTERNAL_NETWORK: "true"` if Immich or other integrated services are hosted on a private/RFC-1918 address (e.g. a pod on the same cluster or a NAS on your LAN). Loopback (`127.x`) and link-local/metadata addresses (`169.254.x`) remain blocked regardless.
- Set `env.ALLOW_LINK_LOCAL_IPS` to a single link-local address (e.g. `"169.254.1.2"`) only when a service TREK needs sits behind one, such as a host gateway. Cloud metadata addresses (`169.254.169.x`, `169.254.170.x`) cannot be listed.
- `FORCE_HTTPS` is optional. Set `env.FORCE_HTTPS: "true"` only when ingress (or another proxy) terminates TLS. It enables HTTPS redirects, HSTS, CSP `upgrade-insecure-requests`, and forces the session cookie `secure` flag. Requires `TRUST_PROXY` to be set.
@@ -56,5 +55,3 @@ See `values.yaml` for more options.
- `COOKIE_SECURE` is auto-derived (on when `NODE_ENV=production` or `FORCE_HTTPS=true`). Set `env.COOKIE_SECURE: "false"` only during local testing without TLS. **Not recommended for production.**
- Web Push needs no chart settings: TREK generates its key pair on first start and keeps it in the database on the data PVC. To bring your own pair, set `env.VAPID_PUBLIC_KEY` and `secretEnv.VAPID_PRIVATE_KEY` together (and optionally `env.VAPID_SUBJECT`); changing the pair later means every device has to turn push on again.
- Set `env.OIDC_DISCOVERY_URL` to override the auto-constructed OIDC discovery endpoint. Required for providers (e.g. Authentik) that expose it at a non-standard path.
- `podSecurityContext` defaults to `fsGroup: 1000` with `fsGroupChangePolicy: OnRootMismatch`, and `securityContext` (the container's) is empty. The container then starts as root, hands `/app/data` and `/app/uploads` to `node` (uid 1000) if the directory or anything directly inside it is not owned by `node` yet, and drops to `node`. Deeper files are not checked. Only the owning user is changed (plus a `root` group), so a different `fsGroup` stays in place. A volume that refuses `chown` (NFS with `root_squash`) logs a warning instead; set `runAsUser: 1000` then. To run as `node` from the start, for example under the `restricted` Pod Security Standard, set `podSecurityContext.runAsUser: 1000` and `runAsGroup: 1000`; the image then skips that step and the privilege drop. See the example in `values.yaml`.
- Use `extraVolumes` and `extraVolumeMounts` to add your own volumes to the pod. To give the booking import more KItinerary extractors, mount a ConfigMap at `/usr/local/share/kitinerary/extractors`, with one `.json` and one `.js` key for each extractor; see the example in `values.yaml`.
+2 -2
View File
@@ -1,5 +1,5 @@
apiVersion: v2
name: trek
version: 5.1.0
version: 4.3.4
description: Minimal Helm chart for TREK app
appVersion: "5.1.0"
appVersion: "4.3.4"
-9
View File
@@ -31,12 +31,3 @@
The other values for that volume (size, storageClassName, annotations) are then ignored.
- With `persistence.enabled=false` the volumes use an emptyDir — storage is ephemeral and is lost
when the pod restarts. Use only for testing.
{{- if .Values.httpRoute.enabled }}
6. HTTPRoute:
- The chart creates an HTTPRoute v1 to the existing TREK Service. The Gateway, GatewayClass,
listeners, TLS certificates and allowedRoutes are managed by your cluster operator.
- Ingress and HTTPRoute may be enabled together.
- Timeout fields are Standard Channel in Gateway API v1.2, but controller support varies. Omit
them to use controller defaults; `0s` disables the corresponding timeout where supported.
{{- end }}
-18
View File
@@ -112,24 +112,6 @@ data:
{{- if .Values.env.TREK_PLACES_URL }}
TREK_PLACES_URL: {{ .Values.env.TREK_PLACES_URL | quote }}
{{- end }}
{{- if .Values.env.TREK_PLACES_CONTRIBUTE }}
TREK_PLACES_CONTRIBUTE: {{ .Values.env.TREK_PLACES_CONTRIBUTE | quote }}
{{- end }}
{{- if .Values.env.TREK_SERVICES }}
TREK_SERVICES: {{ .Values.env.TREK_SERVICES | quote }}
{{- end }}
{{- if .Values.env.TREK_SERVICES_HOSTS }}
TREK_SERVICES_HOSTS: {{ .Values.env.TREK_SERVICES_HOSTS | quote }}
{{- end }}
{{- if .Values.env.TREK_PHOTON_URL }}
TREK_PHOTON_URL: {{ .Values.env.TREK_PHOTON_URL | quote }}
{{- end }}
{{- if .Values.env.TREK_VALHALLA_URL }}
TREK_VALHALLA_URL: {{ .Values.env.TREK_VALHALLA_URL | quote }}
{{- end }}
{{- if .Values.env.GOOGLE_LIST_DATA_BASE }}
GOOGLE_LIST_DATA_BASE: {{ .Values.env.GOOGLE_LIST_DATA_BASE | quote }}
{{- end }}
{{- if .Values.env.AMAP_API_BASE }}
AMAP_API_BASE: {{ .Values.env.AMAP_API_BASE | quote }}
{{- end }}
+1 -19
View File
@@ -29,18 +29,12 @@ spec:
- name: {{ .name }}
{{- end }}
{{- end }}
{{- with .Values.podSecurityContext }}
securityContext:
{{- toYaml . | nindent 8 }}
{{- end }}
fsGroup: 1000
containers:
- name: trek
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
{{- with .Values.securityContext }}
securityContext:
{{- toYaml . | nindent 12 }}
{{- end }}
{{- with .Values.resources }}
resources:
{{- toYaml . | nindent 12 }}
@@ -91,12 +85,6 @@ spec:
name: {{ default (printf "%s-secret" (include "trek.fullname" .)) .Values.existingSecret }}
key: PLACES_API_KEY
optional: true
- name: TREK_SERVICES_KEY
valueFrom:
secretKeyRef:
name: {{ default (printf "%s-secret" (include "trek.fullname" .)) .Values.existingSecret }}
key: TREK_SERVICES_KEY
optional: true
- name: AMAP_API_KEY
valueFrom:
secretKeyRef:
@@ -147,9 +135,6 @@ spec:
mountPath: /app/data
- name: uploads
mountPath: /app/uploads
{{- with .Values.extraVolumeMounts }}
{{- toYaml . | nindent 12 }}
{{- end }}
{{- with (default dict .Values.probes).startup }}
startupProbe:
{{- toYaml . | nindent 12 }}
@@ -177,6 +162,3 @@ spec:
{{- else }}
emptyDir: {}
{{- end }}
{{- with .Values.extraVolumes }}
{{- toYaml . | nindent 8 }}
{{- end }}
-39
View File
@@ -1,39 +0,0 @@
{{- if .Values.httpRoute.enabled -}}
{{- if not .Values.httpRoute.parentRefs }}
{{- fail "httpRoute.parentRefs must include at least one parent" }}
{{- end }}
{{- if not .Values.httpRoute.paths }}
{{- fail "httpRoute.paths must include at least one path" }}
{{- end }}
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: {{ include "trek.fullname" . }}
labels:
app: {{ include "trek.name" . }}
{{- with .Values.httpRoute.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
parentRefs:
{{- toYaml .Values.httpRoute.parentRefs | nindent 4 }}
{{- with .Values.httpRoute.hostnames }}
hostnames:
{{- toYaml . | nindent 4 }}
{{- end }}
rules:
{{- range $path := .Values.httpRoute.paths }}
- matches:
- path:
type: PathPrefix
value: {{ $path | quote }}
backendRefs:
- name: {{ include "trek.fullname" $ }}
port: {{ $.Values.service.port }}
{{- with $.Values.httpRoute.timeouts }}
timeouts:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- end }}
{{- end }}
-6
View File
@@ -23,9 +23,6 @@ data:
{{- if .Values.secretEnv.PLACES_API_KEY }}
PLACES_API_KEY: {{ .Values.secretEnv.PLACES_API_KEY | b64enc | quote }}
{{- end }}
{{- if .Values.secretEnv.TREK_SERVICES_KEY }}
TREK_SERVICES_KEY: {{ .Values.secretEnv.TREK_SERVICES_KEY | b64enc | quote }}
{{- end }}
{{- if .Values.secretEnv.AMAP_API_KEY }}
AMAP_API_KEY: {{ .Values.secretEnv.AMAP_API_KEY | b64enc | quote }}
{{- end }}
@@ -80,9 +77,6 @@ stringData:
{{- if .Values.secretEnv.PLACES_API_KEY }}
PLACES_API_KEY: {{ .Values.secretEnv.PLACES_API_KEY | quote }}
{{- end }}
{{- if .Values.secretEnv.TREK_SERVICES_KEY }}
TREK_SERVICES_KEY: {{ .Values.secretEnv.TREK_SERVICES_KEY | quote }}
{{- end }}
{{- if .Values.secretEnv.AMAP_API_KEY }}
AMAP_API_KEY: {{ .Values.secretEnv.AMAP_API_KEY | quote }}
{{- end }}
+3 -85
View File
@@ -10,34 +10,6 @@ image:
# processes on one host, which network filesystems do not provide.
updateStrategy: Recreate
# Pod-level security context. fsGroup gives the node user (uid/gid 1000) group access
# to the data and uploads volumes; OnRootMismatch lets the kubelet skip its recursive
# permission walk once the volume root already matches, so a big PVC does not hold up
# every start.
#
# By default the container starts as root, hands any files that are not node's yet to
# node, and then drops to node itself. To start it as node straight away (needed under
# the "restricted" Pod Security Standard), add the user here and lock the container
# down below; the image then skips the ownership step and the privilege drop:
# podSecurityContext:
# fsGroup: 1000
# fsGroupChangePolicy: OnRootMismatch
# runAsUser: 1000
# runAsGroup: 1000
# runAsNonRoot: true
# seccompProfile:
# type: RuntimeDefault
# securityContext:
# allowPrivilegeEscalation: false
# capabilities:
# drop: [ALL]
podSecurityContext:
fsGroup: 1000
fsGroupChangePolicy: OnRootMismatch
# Security context of the trek container itself. Empty by default.
securityContext: {}
# Optional image pull secrets for private registries
imagePullSecrets: []
# - name: my-registry-secret
@@ -64,7 +36,7 @@ env:
# Browser/OS language is auto-detected first; this is the fallback when no match is found.
# Supported: ar, az, br, ca, cs, de, en, es, et, fr, gr, hu, id, it, ja, ko, nl, pl, ru, sk, sv, th, tr, uk, vi, zh, zh-TW
# ALLOWED_ORIGINS: ""
# NOTE: If using ingress or HTTPRoute, ensure env.ALLOWED_ORIGINS matches the public domains for proper CORS configuration.
# NOTE: If using ingress, ensure env.ALLOWED_ORIGINS matches the domains in ingress.hosts for proper CORS configuration.
# APP_URL: "https://trek.example.com"
# Public base URL of this instance. Required when OIDC is enabled — must match the redirect URI registered with your IdP.
# Also used as the base URL for links in email notifications and other external links.
@@ -120,8 +92,7 @@ env:
# Custom Nominatim base URL for every geocoding call the server makes (place search, reverse lookup, Atlas region
# fill). When set it REPLACES the public OpenStreetMap service, with no fallback, so the instance behind it needs
# the coverage you expect and has to speak the Nominatim API. TREK keeps its ~1 request per second spacing either
# way, and search and reverse lookups no longer go to the TREK services' Photon. Defaults to
# https://nominatim.openstreetmap.org.
# way. Defaults to https://nominatim.openstreetmap.org.
# OVERPASS_URL: ""
# Custom Overpass endpoint(s) for the map POI "explore" search, comma-separated. When set, REPLACES the bundled
# public mirrors — point it at an internal/self-hosted Overpass instance when the public mirrors are unreachable
@@ -137,28 +108,9 @@ env:
# nothing there. There is no switch in the admin panel on purpose: whether searches leave the instance is decided
# here. Defaults to on.
# TREK_PLACES_URL: ""
# GOOGLE_LIST_DATA_BASE: ""
# Base URL of a copy of the Places API you run yourself; it has to answer the same /v1 API. Read from
# configuration rather than typed by a user, so an in-cluster address works without ALLOW_INTERNAL_NETWORK.
# Defaults to https://places.liketrek.com.
# TREK_PLACES_CONTRIBUTE: "off"
# Whether an admin may let this installation send the place details its users typed in (website, phone, e-mail,
# opening hours, description, picture) to the TREK Places API. "admin" leaves the choice to the admin panel, where
# it stays off until an admin agrees; "off" takes it away. Defaults to admin.
# TREK_SERVICES: "off"
# Places search identifies this installation with a key it registers by itself on first use and keeps in the
# database. "off" sends no key; searches then use the shared pool. A key issued to you goes under
# secretEnv.TREK_SERVICES_KEY. Defaults to auto.
# TREK_SERVICES_HOSTS: ""
# Comma-separated hosts that receive the installation key over https. Defaults to
# places.liketrek.com,photon.liketrek.com,valhalla.liketrek.com.
# TREK_PHOTON_URL: ""
# The address search (Photon) of the TREK services. With a key, search and reverse lookups ask it before
# Nominatim and fall back to Nominatim when it does not answer. Never asked when NOMINATIM_URL is set.
# Defaults to https://photon.liketrek.com.
# TREK_VALHALLA_URL: ""
# The Valhalla of the TREK services, which this server relays routing to unless the admin picks another
# routing service. The browser never talks to it. Defaults to https://valhalla.liketrek.com.
# AMAP_API_BASE: ""
# Send the Amap (高德地图) calls through an egress proxy, a cache or a gateway that speaks the same API instead of
# restapi.amap.com directly. The key itself is a credential and goes under secretEnv.AMAP_API_KEY, or into
@@ -211,9 +163,6 @@ secretEnv:
# with signing enabled, because such a key rejects every unsigned request. Leave empty
# otherwise. The endpoint itself is not a secret and is set under env.AMAP_API_BASE.
AMAP_API_SECRET: ""
# Optional key issued for this installation by the TREK services. Wins over the key TREK
# registers by itself and turns that registration off. Leave empty to let TREK register.
TREK_SERVICES_KEY: ""
# Optional Web Push key pair, private half (base64url, 32 bytes). Only used together with
# env.VAPID_PUBLIC_KEY; a pair whose halves do not belong together, or only one half, turns
# push off with an error in the log until it is fixed, and every device stays registered.
@@ -229,7 +178,7 @@ generateEncryptionKey: false
# If set, the chart renders no Secret of its own and instead reads every secret env
# var from this existing Kubernetes secret. Expected keys: ENCRYPTION_KEY (rename via
# existingSecretKey), ADMIN_EMAIL, ADMIN_PASSWORD, OIDC_CLIENT_SECRET,
# UNSPLASH_ACCESS_KEY, PLACES_API_KEY, AMAP_API_KEY, AMAP_API_SECRET, TREK_SERVICES_KEY,
# UNSPLASH_ACCESS_KEY, PLACES_API_KEY, AMAP_API_KEY, AMAP_API_SECRET,
# VAPID_PRIVATE_KEY, SMTP_PASS, MAPBOX_ACCESS_TOKEN, CARTO_API_KEY, DEMO_ADMIN_PASS.
# Missing keys are simply left unset (they are optional), so only provide the ones your
# setup needs.
@@ -292,20 +241,6 @@ extraEnv: []
# name: trek-smtp
extraEnvFrom: []
# Further volumes for the pod. The chart does not create the objects they name.
# extraVolumes:
# - name: kitinerary-extractors
# configMap:
# name: trek-kitinerary-extractors
extraVolumes: []
# Where the trek container mounts them. For example, KItinerary
# loads extractors for the booking import from this path:
# extraVolumeMounts:
# - name: kitinerary-extractors
# mountPath: /usr/local/share/kitinerary/extractors
# readOnly: true
extraVolumeMounts: []
resources:
requests:
cpu: 100m
@@ -326,20 +261,3 @@ ingress:
# - secretName: chart-example-tls
# hosts:
# - chart-example.local
httpRoute:
enabled: false
annotations: {}
parentRefs: []
# - name: gateway
# namespace: gateway-system
# sectionName: https
hostnames: []
# - trek.example.com
paths:
- /
# Timeout fields are Standard Channel in Gateway API v1.2; controller support varies.
# Leave empty for controller defaults. Use "0s" to disable the corresponding timeout.
timeouts: {}
# request: "0s"
# backendRequest: "0s"
-4
View File
@@ -7,7 +7,3 @@ playwright/.cache/
# vite-plugin-pwa dev output (devOptions.enabled)
dev-dist/
e2e/help/media.env
# The native apps' address screen (npm run build:native), copied into
# android/ and ios/ by `cap sync`
native-dist/
-108
View File
@@ -1,108 +0,0 @@
# Using Android gitignore template: https://github.com/github/gitignore/blob/HEAD/Android.gitignore
# Built application files
*.apk
*.aar
*.ap_
*.aab
# Files for the ART/Dalvik VM
*.dex
# Java class files
*.class
# Generated files
bin/
gen/
out/
# Uncomment the following line in case you need and you don't have the release build type files in your app
# release/
# Gradle files
.gradle/
build/
# Local configuration file (sdk path, etc)
local.properties
# Proguard folder generated by Eclipse
proguard/
# Log Files
*.log
# Android Studio Navigation editor temp files
.navigation/
# Android Studio captures folder
captures/
# IntelliJ
*.iml
.idea/workspace.xml
.idea/tasks.xml
.idea/gradle.xml
.idea/assetWizardSettings.xml
.idea/dictionaries
.idea/libraries
# Android Studio 3 in .gitignore file.
.idea/caches
.idea/modules.xml
# Comment next line if keeping position of elements in Navigation Editor is relevant for you
.idea/navEditor.xml
# Keystore files and store credentials. The upload key and the Play service
# account live only in the release workflow's secrets and in the owner's
# offline backup, never in the repository.
*.jks
*.keystore
keystore.properties
*service-account*.json
# External native build folder generated in Android Studio 2.2 and later
.externalNativeBuild
.cxx/
# Google Services (e.g. APIs or Firebase)
# Push is not wired up and the build does not apply the google-services plugin,
# so no CI secret is needed. Kept ignored so a stray Firebase config never lands
# in the repository.
google-services.json
app/google-services.json
# Freeline
freeline.py
freeline/
freeline_project_description.json
# fastlane
fastlane/report.xml
fastlane/Preview.html
fastlane/screenshots
fastlane/test_output
fastlane/readme.md
# Version control
vcs.xml
# lint
lint/intermediates/
lint/generated/
lint/outputs/
lint/tmp/
# lint/reports/
# Android Profiling
*.hprof
# Cordova plugins for Capacitor
capacitor-cordova-android-plugins
# Copied web assets
app/src/main/assets/public
# Generated Config files
app/src/main/assets/capacitor.config.json
app/src/main/assets/capacitor.plugins.json
app/src/main/res/xml/config.xml
-2
View File
@@ -1,2 +0,0 @@
/build/*
!/build/.npmkeep
-70
View File
@@ -1,70 +0,0 @@
apply plugin: 'com.android.application'
android {
namespace = "com.liketrek.trek"
compileSdk = rootProject.ext.compileSdkVersion
defaultConfig {
applicationId "com.liketrek.trek"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
// The release workflow (.github/workflows/native-release.yml) passes both:
// the version from client/package.json and a build number that only grows.
// Local and PR builds fall back to these placeholders.
versionCode((project.findProperty('trekVersionCode') ?: '1') as Integer)
versionName(project.findProperty('trekVersionName') ?: '0.0.0-dev')
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
// Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61
ignoreAssetsPattern = '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
}
}
// The upload key only exists in the release workflow, which decodes it from a
// secret and passes its path and passwords as Gradle properties. Every other
// build (local, fork, PR) stays unsigned and needs no secret.
signingConfigs {
release {
if (project.hasProperty('trekKeystore')) {
storeFile file(project.property('trekKeystore'))
storePassword project.property('trekKeystorePassword')
keyAlias project.property('trekKeyAlias')
keyPassword project.property('trekKeyPassword')
}
}
}
buildTypes {
release {
if (project.hasProperty('trekKeystore')) {
signingConfig signingConfigs.release
}
minifyEnabled true
shrinkResources true
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
}
repositories {
flatDir{
dirs '../capacitor-cordova-android-plugins/src/main/libs', 'libs'
}
}
dependencies {
implementation fileTree(include: ['*.jar'], dir: 'libs')
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
implementation "androidx.browser:browser:$androidxBrowserVersion"
implementation "androidx.biometric:biometric:1.1.0"
// Push through liketrek's relay. No google-services plugin: Firebase reads
// its project from res/values/firebase.xml, the strings the plugin would generate.
implementation "com.google.firebase:firebase-messaging:24.1.2"
implementation project(':capacitor-android')
testImplementation "junit:junit:$junitVersion"
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
implementation project(':capacitor-cordova-android-plugins')
}
apply from: 'capacitor.build.gradle'
-25
View File
@@ -1,25 +0,0 @@
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
android {
compileOptions {
sourceCompatibility JavaVersion.VERSION_21
targetCompatibility JavaVersion.VERSION_21
}
}
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
dependencies {
implementation project(':capacitor-app')
implementation project(':capacitor-filesystem')
implementation project(':capacitor-haptics')
implementation project(':capacitor-keyboard')
implementation project(':capacitor-share')
implementation project(':capacitor-splash-screen')
implementation project(':capacitor-status-bar')
}
if (hasProperty('postBuildExtras')) {
postBuildExtras()
}
-38
View File
@@ -1,38 +0,0 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
# TREK's own classes: TrekShellPlugin is reached from JavaScript by name and its
# methods through reflection, so R8 must neither rename nor drop them.
-keep class com.liketrek.trek.** { *; }
# Every Capacitor plugin: the bridge finds plugins by their annotation and calls
# the annotated methods reflectively.
-keep @com.getcapacitor.annotation.CapacitorPlugin public class * {
@com.getcapacitor.PluginMethod public <methods>;
}
# The WebView bridge: Capacitor's message handler is called from JavaScript
# through @JavascriptInterface, and every plugin class is instantiated by name.
-keepclassmembers class * {
@android.webkit.JavascriptInterface <methods>;
}
-keep public class * extends com.getcapacitor.Plugin
@@ -1,101 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<application
android:allowBackup="false"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:networkSecurityConfig="@xml/network_security_config"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/AppTheme">
<activity
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|navigation|density"
android:name=".MainActivity"
android:label="@string/title_activity_main"
android:theme="@style/AppTheme.NoActionBarLaunch"
android:launchMode="singleTask"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- "Open in TREK" / share a booking document into the import. -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="application/pdf" />
<data android:mimeType="text/calendar" />
<data android:mimeType="application/vnd.apple.pkpass" />
<data android:mimeType="message/rfc822" />
</intent-filter>
<!-- Deep links.
The scheme is @string/custom_url_scheme (com.liketrek.trek).
launchMode is singleTask above, so a link delivers into the
running task via onNewIntent rather than starting a second copy
of the app on top of the first.
This is the return path for SSO and passkey sign-in: TrekShell
opens the server's login page in a Custom Tab, and after the
hand-off it redirects to com.liketrek.trek://auth?code=…, which
lands here and in TrekShellPlugin.handleOnNewIntent.
Not an App Link (autoVerify + https). App Links are verified
against a specific domain via assetlinks.json, and TREK is
self-hosted — there is no single domain to verify against. A
custom scheme is the only form that works for every user's
server. -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="@string/custom_url_scheme" />
</intent-filter>
</activity>
<!-- Push: the relay's data messages, decrypted and shown here. -->
<service
android:name=".TrekPushService"
android:exported="false">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</service>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths"></meta-data>
</provider>
</application>
<!-- Permissions. Only what the WebView itself needs: the file picker's
camera option and navigator.geolocation for "locate me" on the map,
both granted at runtime through Capacitor's WebChromeClient. -->
<uses-permission android:name="android.permission.INTERNET" />
<!-- COARSE is listed first because it is the weaker grant and is enough
for "what city am I in". -->
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<!-- required="false" on purpose: TREK is a planner, and everything except
"locate me" works without GPS hardware. Marking it required would have
Play hide the app from tablets and other GPS-less devices. -->
<uses-feature android:name="android.hardware.location.gps" android:required="false" />
<uses-permission android:name="android.permission.CAMERA" />
<!-- Android 13 and later ask before an app may show notifications. -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-feature android:name="android.hardware.camera" android:required="false" />
</manifest>
@@ -1,57 +0,0 @@
package com.liketrek.trek;
import android.content.Intent;
import android.os.Bundle;
import android.view.View;
import com.getcapacitor.BridgeActivity;
public class MainActivity extends BridgeActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
registerPlugin(TrekShellPlugin.class);
// With a server picked, the WebView loads that server instead of the
// bundled address screen, so the UI arrives in the version the server
// runs and on its own origin. BridgeActivity reads `config` while it
// builds the bridge inside super.onCreate().
boolean restarting = TrekServerStore.consumeRestart(this);
String server = TrekServerStore.getUrl(this);
boolean onServer = server != null && !TrekServerStore.consumeShellOnce(this);
config = TrekServerStore.configFor(this, onServer ? server : null, restarting);
super.onCreate(savedInstanceState);
if (bridge == null) return;
// The UI asks for no overscroll effect (overscroll-behavior: none on html)
// and Chrome honours that, but the WebView draws the stretch on its root
// scroller natively. It scales the whole view, top bar and bottom dock included.
bridge.getWebView().setOverScrollMode(View.OVER_SCROLL_NEVER);
// Set before any WebView callback can run: they are posted to this thread.
if (onServer) bridge.setWebViewClient(new TrekWebViewClient(bridge, this::showAddressScreen));
TrekSharedFiles.receive(this, getIntent());
}
@Override
public void onNewIntent(Intent intent) {
super.onNewIntent(intent);
TrekSharedFiles.receive(this, intent);
}
private boolean leavingServer;
/**
* The server did not load: back to the address screen, which offers retry
* and change. A fresh task rather than recreate(), because this usually
* happens during launch, while the system splash is still up, and a
* recreated activity leaves that splash lying over the new page.
*/
private void showAddressScreen() {
// A failed page can report more than one error; restart only once.
if (leavingServer) return;
leavingServer = true;
TrekServerStore.showShellOnce(this);
TrekServerStore.markRestart(this);
runOnUiThread(() -> {
startActivity(new Intent(this, MainActivity.class).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK));
overridePendingTransition(0, 0);
});
}
}
@@ -1,139 +0,0 @@
package com.liketrek.trek;
import android.content.Context;
import android.content.SharedPreferences;
import android.security.keystore.KeyGenParameterSpec;
import android.security.keystore.KeyProperties;
import android.util.Base64;
import java.math.BigInteger;
import java.security.KeyFactory;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.KeyStore;
import java.security.interfaces.ECPrivateKey;
import java.security.interfaces.ECPublicKey;
import java.security.spec.ECGenParameterSpec;
import java.security.spec.PKCS8EncodedKeySpec;
import java.security.SecureRandom;
import javax.crypto.Cipher;
import javax.crypto.KeyGenerator;
import javax.crypto.SecretKey;
import javax.crypto.spec.GCMParameterSpec;
/**
* The device's Web Push key pair (P-256) and auth secret (RFC 8291). The server
* encrypts every notification for these, and TrekPushService decrypts with
* them. They are kept in the app's preferences, sealed with an AES key that
* never leaves the Android Keystore, so a copy of the preferences alone opens
* nothing.
*/
final class TrekPushKeys {
private static final String PREFS = "trek_push";
private static final String WRAP_ALIAS = "trek_push_wrap";
private static final String PRIVATE = "private";
private static final String PUBLIC = "public";
private static final String AUTH = "auth";
final ECPrivateKey privateKey;
/** Uncompressed point, 65 bytes, as the server reads p256dh. */
final byte[] publicKey;
final byte[] auth;
TrekPushKeys(ECPrivateKey privateKey, byte[] publicKey, byte[] auth) {
this.privateKey = privateKey;
this.publicKey = publicKey;
this.auth = auth;
}
/** The stored keys, or null when the app never subscribed. */
static synchronized TrekPushKeys load(Context context) throws Exception {
SharedPreferences prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE);
String sealedPrivate = prefs.getString(PRIVATE, null);
String publicKey = prefs.getString(PUBLIC, null);
String sealedAuth = prefs.getString(AUTH, null);
if (sealedPrivate == null || publicKey == null || sealedAuth == null) return null;
byte[] pkcs8 = unseal(sealedPrivate);
ECPrivateKey key = (ECPrivateKey) KeyFactory.getInstance("EC").generatePrivate(new PKCS8EncodedKeySpec(pkcs8));
return new TrekPushKeys(key, Base64.decode(publicKey, Base64.NO_WRAP), unseal(sealedAuth));
}
static synchronized TrekPushKeys loadOrCreate(Context context) throws Exception {
TrekPushKeys existing;
try {
existing = load(context);
} catch (Exception unreadable) {
// The Keystore key is gone (backup restored to another device, data
// cleared): start over, the next subscription carries the new keys.
existing = null;
}
if (existing != null) return existing;
KeyPairGenerator generator = KeyPairGenerator.getInstance("EC");
generator.initialize(new ECGenParameterSpec("secp256r1"));
KeyPair pair = generator.generateKeyPair();
byte[] publicKey = uncompressed((ECPublicKey) pair.getPublic());
byte[] auth = new byte[16];
new SecureRandom().nextBytes(auth);
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.edit()
.putString(PRIVATE, seal(pair.getPrivate().getEncoded()))
.putString(PUBLIC, Base64.encodeToString(publicKey, Base64.NO_WRAP))
.putString(AUTH, seal(auth))
.apply();
return new TrekPushKeys((ECPrivateKey) pair.getPrivate(), publicKey, auth);
}
static String base64url(byte[] data) {
return Base64.encodeToString(data, Base64.URL_SAFE | Base64.NO_WRAP | Base64.NO_PADDING);
}
static byte[] uncompressed(ECPublicKey key) {
byte[] out = new byte[65];
out[0] = 0x04;
copyFixed(key.getW().getAffineX(), out, 1);
copyFixed(key.getW().getAffineY(), out, 33);
return out;
}
/** A coordinate as exactly 32 bytes: BigInteger adds a sign byte or drops leading zeros. */
private static void copyFixed(BigInteger value, byte[] out, int offset) {
byte[] raw = value.toByteArray();
int start = Math.max(0, raw.length - 32);
int length = raw.length - start;
System.arraycopy(raw, start, out, offset + 32 - length, length);
}
private static SecretKey wrapKey() throws Exception {
KeyStore store = KeyStore.getInstance("AndroidKeyStore");
store.load(null);
if (store.containsAlias(WRAP_ALIAS)) return ((KeyStore.SecretKeyEntry) store.getEntry(WRAP_ALIAS, null)).getSecretKey();
KeyGenerator generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore");
generator.init(
new KeyGenParameterSpec.Builder(WRAP_ALIAS, KeyProperties.PURPOSE_ENCRYPT | KeyProperties.PURPOSE_DECRYPT)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setKeySize(256)
.build()
);
return generator.generateKey();
}
private static String seal(byte[] plain) throws Exception {
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(Cipher.ENCRYPT_MODE, wrapKey());
byte[] iv = cipher.getIV();
byte[] sealed = cipher.doFinal(plain);
byte[] out = new byte[iv.length + sealed.length];
System.arraycopy(iv, 0, out, 0, iv.length);
System.arraycopy(sealed, 0, out, iv.length, sealed.length);
return Base64.encodeToString(out, Base64.NO_WRAP);
}
private static byte[] unseal(String value) throws Exception {
byte[] in = Base64.decode(value, Base64.NO_WRAP);
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(Cipher.DECRYPT_MODE, wrapKey(), new GCMParameterSpec(128, in, 0, 12));
return cipher.doFinal(in, 12, in.length - 12);
}
}
@@ -1,101 +0,0 @@
package com.liketrek.trek;
import android.Manifest;
import android.app.NotificationChannel;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.content.Context;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.os.Build;
import android.util.Base64;
import androidx.annotation.NonNull;
import androidx.core.app.NotificationCompat;
import androidx.core.app.NotificationManagerCompat;
import androidx.core.content.ContextCompat;
import com.google.firebase.messaging.FirebaseMessagingService;
import com.google.firebase.messaging.RemoteMessage;
import java.nio.charset.StandardCharsets;
import org.json.JSONObject;
/**
* Receives the relay's data messages ({enc}: the server's encrypted Web Push
* record), decrypts them with the device's keys and shows the notification.
* Without the keys, or when the record does not open, it shows the same
* neutral text the iOS extension falls back to, never the ciphertext.
*/
public class TrekPushService extends FirebaseMessagingService {
static final String CHANNEL = "trek";
static final String EXTRA_URL = "trek.pushUrl";
@Override
public void onMessageReceived(@NonNull RemoteMessage message) {
String enc = message.getData().get("enc");
if (enc == null) return;
String title = getString(R.string.push_fallback_title);
String body = getString(R.string.push_fallback_body);
String url = null;
String tag = null;
try {
TrekPushKeys keys = TrekPushKeys.load(this);
if (keys != null) {
byte[] plain = TrekWebPush.decrypt(Base64.decode(enc, Base64.DEFAULT), keys);
JSONObject payload = new JSONObject(new String(plain, StandardCharsets.UTF_8));
if (!payload.optString("title").isEmpty()) title = payload.optString("title");
body = payload.optString("body", body);
url = payload.optString("url", null);
tag = payload.optString("tag", null);
}
} catch (Exception unreadable) {
// Shown with the neutral text: the tap still opens the app.
}
show(this, title, body, url, tag);
}
/** A new token means a new endpoint; the UI re-registers on its next start (resyncPushSubscription). */
@Override
public void onNewToken(@NonNull String token) {}
static void ensureChannel(Context context) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return;
NotificationManager manager = context.getSystemService(NotificationManager.class);
if (manager == null || manager.getNotificationChannel(CHANNEL) != null) return;
NotificationChannel channel = new NotificationChannel(
CHANNEL,
context.getString(R.string.push_channel_name),
NotificationManager.IMPORTANCE_DEFAULT
);
manager.createNotificationChannel(channel);
}
private static void show(Context context, String title, String body, String url, String tag) {
ensureChannel(context);
Intent open = new Intent(context, MainActivity.class)
.setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP | Intent.FLAG_ACTIVITY_CLEAR_TOP);
if (url != null) open.putExtra(EXTRA_URL, url);
int id = tag != null ? tag.hashCode() : (int) (System.currentTimeMillis() & 0x7fffffff);
PendingIntent tap = PendingIntent.getActivity(
context,
id,
open,
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE
);
NotificationCompat.Builder builder = new NotificationCompat.Builder(context, CHANNEL)
.setSmallIcon(R.drawable.ic_stat_trek)
.setContentTitle(title)
.setContentText(body)
.setStyle(new NotificationCompat.BigTextStyle().bigText(body))
.setAutoCancel(true)
.setContentIntent(tap);
if (Build.VERSION.SDK_INT >= 33
&& ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
return;
}
try {
NotificationManagerCompat.from(context).notify(tag, id, builder.build());
} catch (SecurityException notAllowed) {
// Notifications switched off for the app since it subscribed.
}
}
}
@@ -1,123 +0,0 @@
package com.liketrek.trek;
import android.content.Context;
import android.content.SharedPreferences;
import com.getcapacitor.CapConfig;
import com.getcapacitor.Logger;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import org.json.JSONException;
import org.json.JSONObject;
/** The server the user picked, and the Capacitor configuration that loads it. */
final class TrekServerStore {
private static final String PREFS = "trek_shell";
private static final String KEY_URL = "server_url";
private static final String KEY_SHELL_ONCE = "shell_once";
private static final String KEY_RESTART = "restart";
private static final String CONFIG_DIR = "trek-server-config";
private TrekServerStore() {}
static String getUrl(Context context) {
return prefs(context).getString(KEY_URL, null);
}
static void setUrl(Context context, String url) {
SharedPreferences.Editor editor = prefs(context).edit();
if (url == null) {
editor.remove(KEY_URL);
} else {
editor.putString(KEY_URL, url);
}
editor.commit();
}
/** Start the next launch on the bundled address screen, even with a server remembered. */
static void showShellOnce(Context context) {
prefs(context).edit().putBoolean(KEY_SHELL_ONCE, true).commit();
}
static boolean consumeShellOnce(Context context) {
return consume(context, KEY_SHELL_ONCE);
}
/** The next onCreate is the app restarting itself, not a launch. */
static void markRestart(Context context) {
prefs(context).edit().putBoolean(KEY_RESTART, true).commit();
}
static boolean consumeRestart(Context context) {
return consume(context, KEY_RESTART);
}
private static boolean consume(Context context, String key) {
boolean set = prefs(context).getBoolean(key, false);
if (set) prefs(context).edit().remove(key).commit();
return set;
}
/**
* The bundled capacitor.config.json, adjusted for this start. Capacitor only
* takes these settings from a config file, so this writes a copy into the
* app's private storage and loads that; null means the bundled one as it is.
*
* With a server: server.url set, and server.errorPath dropped, because that
* page would have no bridge on the server's origin (TrekWebViewClient
* handles a failed load instead).
*
* On a restart: no launch splash. The splash plugin shows it again after
* recreate() but cannot take it down when the page hides it early, which
* left it covering a fully rendered page. A restart has no launch to cover.
*/
static CapConfig configFor(Context context, String url, boolean restarting) {
if (url == null && !restarting) return null;
try {
JSONObject json = new JSONObject(readAsset(context, "capacitor.config.json"));
if (url != null) {
JSONObject server = child(json, "server");
server.put("url", url);
server.remove("errorPath");
}
if (restarting) {
child(child(json, "plugins"), "SplashScreen").put("launchShowDuration", 0);
}
File dir = new File(context.getFilesDir(), CONFIG_DIR);
if (!dir.isDirectory() && !dir.mkdirs()) throw new IOException("Cannot create " + dir);
try (FileOutputStream out = new FileOutputStream(new File(dir, "capacitor.config.json"))) {
out.write(json.toString().getBytes(StandardCharsets.UTF_8));
}
return CapConfig.loadFromFile(context, dir.getAbsolutePath());
} catch (IOException | JSONException ex) {
Logger.error("TrekShell", "Could not adjust the app config for " + url, ex);
return null;
}
}
private static JSONObject child(JSONObject parent, String key) throws JSONException {
JSONObject existing = parent.optJSONObject(key);
if (existing != null) return existing;
JSONObject created = new JSONObject();
parent.put(key, created);
return created;
}
private static String readAsset(Context context, String name) throws IOException {
try (InputStream in = context.getAssets().open(name); ByteArrayOutputStream out = new ByteArrayOutputStream()) {
byte[] buffer = new byte[8192];
int read;
while ((read = in.read(buffer)) != -1) out.write(buffer, 0, read);
return out.toString("UTF-8");
}
}
private static SharedPreferences prefs(Context context) {
return context.getSharedPreferences(PREFS, Context.MODE_PRIVATE);
}
}
@@ -1,76 +0,0 @@
package com.liketrek.trek;
import android.content.Context;
import android.content.Intent;
import android.database.Cursor;
import android.net.Uri;
import android.provider.OpenableColumns;
import android.util.Base64;
import com.getcapacitor.JSObject;
import java.io.ByteArrayOutputStream;
import java.io.InputStream;
/**
* Booking documents shared to TREK (open with / share), held until the UI takes
* them (TrekShell.takeSharedFile) and announced if it is already running.
*/
final class TrekSharedFiles {
/** Above this the file is not a booking confirmation. */
private static final int MAX_BYTES = 15 * 1024 * 1024;
interface Listener {
void onFile(JSObject file);
}
private static JSObject pending;
private static Listener listener;
private TrekSharedFiles() {}
static void setListener(Listener l) {
listener = l;
}
static JSObject take() {
JSObject file = pending;
pending = null;
return file;
}
static void receive(Context context, Intent intent) {
if (intent == null) return;
Uri uri = null;
if (Intent.ACTION_VIEW.equals(intent.getAction())) uri = intent.getData();
else if (Intent.ACTION_SEND.equals(intent.getAction())) uri = intent.getParcelableExtra(Intent.EXTRA_STREAM);
if (uri == null) return;
try (InputStream in = context.getContentResolver().openInputStream(uri)) {
if (in == null) return;
ByteArrayOutputStream out = new ByteArrayOutputStream();
byte[] buffer = new byte[16384];
int read;
while ((read = in.read(buffer)) != -1) {
out.write(buffer, 0, read);
if (out.size() > MAX_BYTES) return;
}
JSObject file = new JSObject();
file.put("name", displayName(context, uri));
String type = context.getContentResolver().getType(uri);
file.put("mimeType", type == null ? intent.getType() : type);
file.put("data", Base64.encodeToString(out.toByteArray(), Base64.NO_WRAP));
pending = file;
if (listener != null) listener.onFile(file);
} catch (Exception ignored) {
// Unreadable or gone: nothing to import.
}
}
private static String displayName(Context context, Uri uri) {
try (Cursor cursor = context.getContentResolver().query(uri, new String[] { OpenableColumns.DISPLAY_NAME }, null, null, null)) {
if (cursor != null && cursor.moveToFirst()) return cursor.getString(0);
} catch (Exception ignored) {
// Fall through to the path.
}
String last = uri.getLastPathSegment();
return last == null ? "booking" : last;
}
}
@@ -1,351 +0,0 @@
package com.liketrek.trek;
import android.Manifest;
import android.content.Intent;
import android.net.Uri;
import android.os.Build;
import android.os.Handler;
import android.os.Looper;
import androidx.browser.customtabs.CustomTabsIntent;
import androidx.core.app.NotificationManagerCompat;
import com.getcapacitor.JSObject;
import com.getcapacitor.PermissionState;
import com.getcapacitor.Plugin;
import com.getcapacitor.PluginCall;
import com.getcapacitor.PluginMethod;
import com.getcapacitor.annotation.CapacitorPlugin;
import com.getcapacitor.annotation.Permission;
import com.getcapacitor.annotation.PermissionCallback;
import com.google.firebase.messaging.FirebaseMessaging;
import org.json.JSONObject;
/** The native half of client/src/native/trekShell.ts. */
@CapacitorPlugin(
name = "TrekShell",
permissions = { @Permission(strings = { Manifest.permission.POST_NOTIFICATIONS }, alias = "notifications") }
)
public class TrekShellPlugin extends Plugin {
/** How long to wait after returning from the browser for the redirect to land. */
private static final long CANCEL_GRACE_MS = 600;
private String authCallId;
private String authScheme;
private boolean authBrowserOpen;
private final Handler handler = new Handler(Looper.getMainLooper());
/**
* What this binary offers the server's UI, raised with every native feature
* an older binary lacks. The UI asks before it calls (native/capabilities.ts),
* and a server can require a minimum (minNativeShellApi in /api/health/features).
*/
static final int SHELL_API = 2;
/** The native features by name, so the UI can turn each on by itself. */
static final String[] FEATURES = { "capabilities", "biometricLock", "sharedFiles", "nativePush" };
/** liketrek's push relay: the endpoint for this device's FCM token (trek-hosted push-relay.ts). */
static final String PUSH_RELAY = "https://push.liketrek.com/v1/fcm/";
/** The notification the app was started from, until the UI asks for it. */
private String openedPushUrl;
@PluginMethod
public void getCapabilities(PluginCall call) {
JSObject result = new JSObject();
result.put("shellApi", SHELL_API);
result.put("platform", "android");
String version = "";
String build = "";
try {
android.content.pm.PackageInfo info = getContext().getPackageManager().getPackageInfo(getContext().getPackageName(), 0);
version = info.versionName == null ? "" : info.versionName;
build = String.valueOf(androidx.core.content.pm.PackageInfoCompat.getLongVersionCode(info));
} catch (android.content.pm.PackageManager.NameNotFoundException ignored) {
// Our own package is always there; the fields simply stay empty.
}
result.put("appVersion", version);
result.put("build", build);
com.getcapacitor.JSArray features = new com.getcapacitor.JSArray();
for (String feature : FEATURES) features.put(feature);
result.put("features", features);
call.resolve(result);
}
// ---- Shared booking documents ----
@Override
public void load() {
TrekSharedFiles.setListener(file -> notifyListeners("sharedFile", file));
if (getActivity() != null && getActivity().getIntent() != null) {
openedPushUrl = getActivity().getIntent().getStringExtra(TrekPushService.EXTRA_URL);
getActivity().getIntent().removeExtra(TrekPushService.EXTRA_URL);
}
}
// ---- Push ----
/**
* Permission, the FCM token and the device keys, as the Web Push subscription
* the server stores (native/push.ts). Rejects with DENIED when the user
* refuses notifications, as on iOS.
*/
@PluginMethod
public void registerPush(PluginCall call) {
if (Build.VERSION.SDK_INT >= 33 && getPermissionState("notifications") != PermissionState.GRANTED) {
requestPermissionForAlias("notifications", call, "pushPermissionAnswered");
return;
}
subscribePush(call);
}
@PermissionCallback
private void pushPermissionAnswered(PluginCall call) {
if (getPermissionState("notifications") != PermissionState.GRANTED) {
call.reject("Notifications are not allowed", "DENIED");
return;
}
subscribePush(call);
}
private void subscribePush(PluginCall call) {
if (!NotificationManagerCompat.from(getContext()).areNotificationsEnabled()) {
call.reject("Notifications are not allowed", "DENIED");
return;
}
TrekPushService.ensureChannel(getContext());
final TrekPushKeys keys;
try {
keys = TrekPushKeys.loadOrCreate(getContext());
} catch (Exception e) {
call.reject("Could not create the push keys", e);
return;
}
FirebaseMessaging.getInstance().getToken().addOnCompleteListener(task -> {
if (!task.isSuccessful() || task.getResult() == null) {
call.reject("Could not get a push token", task.getException());
return;
}
JSObject subscription = new JSObject();
subscription.put("endpoint", PUSH_RELAY + task.getResult());
JSObject keyObject = new JSObject();
keyObject.put("p256dh", TrekPushKeys.base64url(keys.publicKey));
keyObject.put("auth", TrekPushKeys.base64url(keys.auth));
subscription.put("keys", keyObject);
call.resolve(subscription);
});
}
@PluginMethod
public void takeOpenedPush(PluginCall call) {
JSObject result = new JSObject();
result.put("url", openedPushUrl == null ? JSONObject.NULL : openedPushUrl);
openedPushUrl = null;
call.resolve(result);
}
/** The document shared to TREK before the UI was there to hear of it. */
@PluginMethod
public void takeSharedFile(PluginCall call) {
JSObject result = new JSObject();
JSObject file = TrekSharedFiles.take();
result.put("file", file == null ? JSONObject.NULL : file);
call.resolve(result);
}
// ---- App lock ----
private static final int AUTHENTICATORS =
androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_WEAK
| androidx.biometric.BiometricManager.Authenticators.DEVICE_CREDENTIAL;
/** Which biometry the device offers for the app lock, if any. */
@PluginMethod
public void biometryInfo(PluginCall call) {
int status = androidx.biometric.BiometricManager.from(getContext()).canAuthenticate(AUTHENTICATORS);
JSObject result = new JSObject();
result.put("available", status == androidx.biometric.BiometricManager.BIOMETRIC_SUCCESS);
result.put("type", "biometric");
call.resolve(result);
}
/** The system biometric prompt with the screen lock as the way out. */
@PluginMethod
public void authenticateBiometric(PluginCall call) {
String reason = call.getString("reason", "Unlock TREK");
getActivity().runOnUiThread(() -> {
androidx.fragment.app.FragmentActivity activity = (androidx.fragment.app.FragmentActivity) getActivity();
androidx.biometric.BiometricPrompt prompt = new androidx.biometric.BiometricPrompt(
activity,
androidx.core.content.ContextCompat.getMainExecutor(getContext()),
new androidx.biometric.BiometricPrompt.AuthenticationCallback() {
@Override
public void onAuthenticationSucceeded(androidx.biometric.BiometricPrompt.AuthenticationResult result) {
JSObject ok = new JSObject();
ok.put("ok", true);
call.resolve(ok);
}
@Override
public void onAuthenticationError(int code, CharSequence message) {
boolean cancelled = code == androidx.biometric.BiometricPrompt.ERROR_USER_CANCELED
|| code == androidx.biometric.BiometricPrompt.ERROR_NEGATIVE_BUTTON
|| code == androidx.biometric.BiometricPrompt.ERROR_CANCELED;
call.reject(message.toString(), cancelled ? "CANCELLED" : "FAILED");
}
}
);
androidx.biometric.BiometricPrompt.PromptInfo info = new androidx.biometric.BiometricPrompt.PromptInfo.Builder()
.setTitle(reason)
.setAllowedAuthenticators(AUTHENTICATORS)
.build();
prompt.authenticate(info);
});
}
/** While on, the recent-apps screen shows no snapshot of the app. */
@PluginMethod
public void setPrivacyCover(PluginCall call) {
boolean enabled = Boolean.TRUE.equals(call.getBoolean("enabled", false));
getActivity().runOnUiThread(() -> {
if (android.os.Build.VERSION.SDK_INT >= 33) {
getActivity().setRecentsScreenshotEnabled(!enabled);
} else if (enabled) {
getActivity().getWindow().addFlags(android.view.WindowManager.LayoutParams.FLAG_SECURE);
} else {
getActivity().getWindow().clearFlags(android.view.WindowManager.LayoutParams.FLAG_SECURE);
}
});
call.resolve();
}
@PluginMethod
public void getServer(PluginCall call) {
String url = TrekServerStore.getUrl(getContext());
JSObject result = new JSObject();
result.put("url", url == null ? JSONObject.NULL : url);
call.resolve(result);
}
@PluginMethod
public void setServer(PluginCall call) {
String url = call.getString("url");
Uri parsed = url == null ? null : Uri.parse(url);
if (parsed == null || parsed.getHost() == null || !("https".equals(parsed.getScheme()) || "http".equals(parsed.getScheme()))) {
call.reject("A http(s) URL is required");
return;
}
TrekServerStore.setUrl(getContext(), url);
call.resolve();
restart();
}
@PluginMethod
public void resetServer(PluginCall call) {
TrekServerStore.setUrl(getContext(), null);
call.resolve();
restart();
}
@PluginMethod
public void reload(PluginCall call) {
call.resolve();
restart();
}
/** The server URL is part of the bridge's configuration, so it takes a new activity. */
private void restart() {
TrekServerStore.markRestart(getContext());
getActivity().runOnUiThread(() -> getActivity().recreate());
}
@PluginMethod
public void authenticate(PluginCall call) {
String url = call.getString("url");
String scheme = call.getString("callbackScheme");
if (url == null || scheme == null) {
call.reject("url and callbackScheme are required");
return;
}
rejectPendingAuth("Superseded by a new sign-in");
call.setKeepAlive(true);
getBridge().saveCall(call);
authCallId = call.getCallbackId();
authScheme = scheme;
authBrowserOpen = true;
// A Custom Tab shares the browser's cookies, so a running SSO session at
// the identity provider carries over. The redirect back to the app's
// scheme arrives through the intent-filter in AndroidManifest.xml.
new CustomTabsIntent.Builder().build().launchUrl(getActivity(), Uri.parse(url));
}
@Override
protected void handleOnNewIntent(Intent intent) {
super.handleOnNewIntent(intent);
String pushUrl = intent.getStringExtra(TrekPushService.EXTRA_URL);
if (pushUrl != null) {
intent.removeExtra(TrekPushService.EXTRA_URL);
JSObject opened = new JSObject();
opened.put("url", pushUrl);
notifyListeners("pushOpened", opened);
return;
}
Uri data = intent.getData();
PluginCall call = pendingAuthCall();
if (call == null || data == null || !authScheme.equals(data.getScheme())) return;
JSObject result = new JSObject();
result.put("url", data.toString());
call.resolve(result);
finishAuth(call);
}
/**
* Back in the app without a redirect means the user closed the browser.
* onNewIntent runs before onResume when the redirect did arrive, so the
* grace period only covers devices that deliver the two out of order.
*/
@Override
protected void handleOnResume() {
super.handleOnResume();
if (!authBrowserOpen) return;
authBrowserOpen = false;
handler.postDelayed(() -> {
PluginCall call = pendingAuthCall();
if (call == null) return;
call.reject("Sign-in cancelled", "CANCELLED");
finishAuth(call);
}, CANCEL_GRACE_MS);
}
private PluginCall pendingAuthCall() {
return authCallId == null ? null : getBridge().getSavedCall(authCallId);
}
private void rejectPendingAuth(String message) {
PluginCall call = pendingAuthCall();
if (call == null) return;
call.reject(message, "CANCELLED");
finishAuth(call);
}
private void finishAuth(PluginCall call) {
getBridge().releaseCall(call);
authCallId = null;
authScheme = null;
authBrowserOpen = false;
}
/** iOS only: Android keeps the service worker, which already starts TREK offline. */
@PluginMethod
public void refreshOfflineCopy(PluginCall call) {
JSObject result = new JSObject();
result.put("saved", false);
call.resolve(result);
}
@PluginMethod
public void openOfflineCopy(PluginCall call) {
JSObject result = new JSObject();
result.put("opened", false);
call.resolve(result);
}
}
@@ -1,84 +0,0 @@
package com.liketrek.trek;
import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
import java.security.KeyFactory;
import java.security.interfaces.ECPrivateKey;
import java.security.interfaces.ECPublicKey;
import java.security.spec.ECPoint;
import java.security.spec.ECPublicKeySpec;
import java.util.Arrays;
import javax.crypto.Cipher;
import javax.crypto.KeyAgreement;
import javax.crypto.Mac;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec;
/**
* Decrypts a Web Push record (RFC 8291, aes128gcm content coding of RFC 8188):
* the same thing a browser does, and the counterpart of the iOS notification
* extension's WebPushRecord. One record, as TREK's server always sends.
*/
final class TrekWebPush {
private TrekWebPush() {}
static byte[] decrypt(byte[] record, TrekPushKeys keys) throws Exception {
if (record.length < 21) throw new IllegalArgumentException("record too short");
byte[] salt = Arrays.copyOfRange(record, 0, 16);
int idLength = record[20] & 0xff;
if (idLength != 65 || record.length < 21 + idLength + 17) throw new IllegalArgumentException("unexpected header");
byte[] senderKey = Arrays.copyOfRange(record, 21, 21 + idLength);
byte[] ciphertext = Arrays.copyOfRange(record, 21 + idLength, record.length);
KeyAgreement agreement = KeyAgreement.getInstance("ECDH");
agreement.init(keys.privateKey);
agreement.doPhase(publicKeyFrom(senderKey, keys.privateKey), true);
byte[] shared = agreement.generateSecret();
byte[] keyInfo = concat("WebPush: info\0".getBytes(StandardCharsets.US_ASCII), keys.publicKey, senderKey);
byte[] ikm = hkdf(keys.auth, shared, keyInfo, 32);
byte[] cek = hkdf(salt, ikm, "Content-Encoding: aes128gcm\0".getBytes(StandardCharsets.US_ASCII), 16);
byte[] nonce = hkdf(salt, ikm, "Content-Encoding: nonce\0".getBytes(StandardCharsets.US_ASCII), 12);
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(cek, "AES"), new GCMParameterSpec(128, nonce));
byte[] padded = cipher.doFinal(ciphertext);
// Padding: the content, one delimiter (2 for the last record), then zeros.
int end = padded.length - 1;
while (end >= 0 && padded[end] == 0) end--;
if (end < 0 || padded[end] != 2) throw new IllegalArgumentException("bad padding");
return Arrays.copyOf(padded, end);
}
private static ECPublicKey publicKeyFrom(byte[] point, ECPrivateKey onCurve) throws Exception {
if (point[0] != 0x04) throw new IllegalArgumentException("compressed key");
BigInteger x = new BigInteger(1, Arrays.copyOfRange(point, 1, 33));
BigInteger y = new BigInteger(1, Arrays.copyOfRange(point, 33, 65));
return (ECPublicKey) KeyFactory.getInstance("EC").generatePublic(new ECPublicKeySpec(new ECPoint(x, y), onCurve.getParams()));
}
/** HKDF-SHA256 (RFC 5869) for outputs of at most one block. */
private static byte[] hkdf(byte[] salt, byte[] ikm, byte[] info, int length) throws Exception {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(salt, "HmacSHA256"));
byte[] prk = mac.doFinal(ikm);
mac.init(new SecretKeySpec(prk, "HmacSHA256"));
mac.update(info);
mac.update((byte) 1);
return Arrays.copyOf(mac.doFinal(), length);
}
private static byte[] concat(byte[]... parts) {
int length = 0;
for (byte[] part : parts) length += part.length;
byte[] out = new byte[length];
int offset = 0;
for (byte[] part : parts) {
System.arraycopy(part, 0, out, offset, part.length);
offset += part.length;
}
return out;
}
}
@@ -1,49 +0,0 @@
package com.liketrek.trek;
import android.webkit.WebResourceError;
import android.webkit.WebResourceRequest;
import android.webkit.WebResourceResponse;
import android.webkit.WebView;
import com.getcapacitor.Bridge;
import com.getcapacitor.BridgeWebViewClient;
/**
* Used while the WebView shows the user's server. When that server cannot be
* loaded, Capacitor would show server.errorPath, but on Android it injects
* its bridge only into the server's origin, so that local page could neither
* retry nor change the server and stayed blank. Instead the app restarts on
* the bundled address screen, where the bridge is present and the screen
* offers exactly those two things.
*/
class TrekWebViewClient extends BridgeWebViewClient {
private final Runnable onUnreachable;
TrekWebViewClient(Bridge bridge, Runnable onUnreachable) {
super(bridge);
this.onUnreachable = onUnreachable;
}
@Override
public void onReceivedError(WebView view, WebResourceRequest request, WebResourceError error) {
if (request.isForMainFrame()) {
onUnreachable.run();
return;
}
super.onReceivedError(view, request, error);
}
/**
* Only a 5xx on the page itself means the server is down (a proxy's 502 or
* 503 while TREK restarts). A 4xx page is left alone: the server answered,
* and TREK serves its UI for every other path.
*/
@Override
public void onReceivedHttpError(WebView view, WebResourceRequest request, WebResourceResponse errorResponse) {
if (!request.isForMainFrame()) {
super.onReceivedHttpError(view, request, errorResponse);
} else if (errorResponse.getStatusCode() >= 500) {
onUnreachable.run();
}
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

@@ -1,13 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Status bar icon for notifications: a single-colour map pin, as Android
tints it and shows only its shape. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#FFFFFF">
<path
android:fillColor="#FFFFFF"
android:pathData="M12,2C8.13,2 5,5.13 5,9c0,5.25 7,13 7,13s7,-7.75 7,-13c0,-3.87 -3.13,-7 -7,-7zM12,11.5c-1.38,0 -2.5,-1.12 -2.5,-2.5s1.12,-2.5 2.5,-2.5 2.5,1.12 2.5,2.5 -1.12,2.5 -2.5,2.5z" />
</vector>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.3 KiB

@@ -1,12 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<WebView
android:layout_width="match_parent"
android:layout_height="match_parent" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>
@@ -1,12 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background>
<inset android:drawable="@mipmap/ic_launcher_background" android:inset="16.7%" />
</background>
<foreground>
<inset android:drawable="@mipmap/ic_launcher_foreground" android:inset="16.7%" />
</foreground>
<monochrome>
<inset android:drawable="@mipmap/ic_launcher_foreground" android:inset="16.7%" />
</monochrome>
</adaptive-icon>
@@ -1,12 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background>
<inset android:drawable="@mipmap/ic_launcher_background" android:inset="16.7%" />
</background>
<foreground>
<inset android:drawable="@mipmap/ic_launcher_foreground" android:inset="16.7%" />
</foreground>
<monochrome>
<inset android:drawable="@mipmap/ic_launcher_foreground" android:inset="16.7%" />
</monochrome>
</adaptive-icon>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

@@ -1,13 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- The Firebase project the app receives push through (liketrek push relay).
These are the strings the google-services Gradle plugin would generate from
google-services.json; Firebase reads them at start. None of them is a secret:
they identify the project, sending needs the relay's service account. -->
<resources>
<string name="google_app_id" translatable="false">1:1074454522965:android:3ff2fe270a27a73051b8dc</string>
<string name="gcm_defaultSenderId" translatable="false">1074454522965</string>
<string name="google_api_key" translatable="false">AIzaSyB2VYXmZ90BZuksHhHR_n2W8naNE_vJAJA</string>
<string name="google_crash_reporting_api_key" translatable="false">AIzaSyB2VYXmZ90BZuksHhHR_n2W8naNE_vJAJA</string>
<string name="project_id" translatable="false">trek-9392e</string>
<string name="google_storage_bucket" translatable="false">trek-9392e.firebasestorage.app</string>
</resources>
@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#09090B</color>
<color name="launch_background">#09090B</color>
</resources>
@@ -1,10 +0,0 @@
<?xml version='1.0' encoding='utf-8'?>
<resources>
<string name="app_name">TREK</string>
<string name="title_activity_main">TREK</string>
<string name="package_name">com.liketrek.trek</string>
<string name="custom_url_scheme">com.liketrek.trek</string>
<string name="push_channel_name">Notifications</string>
<string name="push_fallback_title">TREK</string>
<string name="push_fallback_body">New notification</string>
</resources>
@@ -1,25 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Base application theme. -->
<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
<!-- Customize your theme here. -->
<item name="colorPrimary">@color/colorPrimary</item>
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
<item name="colorAccent">@color/colorAccent</item>
</style>
<style name="AppTheme.NoActionBar" parent="Theme.AppCompat.DayNight.NoActionBar">
<item name="windowActionBar">false</item>
<item name="windowNoTitle">true</item>
<item name="android:background">@null</item>
</style>
<style name="AppTheme.NoActionBarLaunch" parent="Theme.SplashScreen">
<item name="android:background">@drawable/splash</item>
<!-- Android 12+ draws its own launch screen: the app icon on this colour.
The same near-black as the icon tile and the first painted frame. -->
<item name="windowSplashScreenBackground">@color/launch_background</item>
</style>
</resources>
@@ -1,4 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<cache-path name="my_cache_images" path="." />
</paths>
@@ -1,30 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
TREK is self-hosted: the user points the app at their own server, so we cannot
know its scheme or its certificate authority ahead of time. Two deliberate
relaxations, both required for that to work at all:
1. cleartextTrafficPermitted="true" — Android blocks http:// by default from
targetSdk 28 on. A bare LAN server (http://192.168.1.10:3001) and a
Tailscale host (100.64.0.0/10, which is NOT RFC1918, so a private-range
allowlist would not cover it) both need this. https:// is unaffected and
remains the recommended setup.
2. <certificates src="user" /> — since Android 7 apps ignore user-installed
CAs unless they opt in. Self-hosters running their own CA (step-ca, mkcert,
an internal PKI) install its root on the device; without this the app would
reject their perfectly valid https:// server. System CAs stay first so
public certs keep validating normally.
The tradeoff is that a user-installed CA can also be a MITM proxy's. That is
the same tradeoff the browser-based PWA already makes, and it is the price of
letting people run their own infrastructure.
-->
<network-security-config>
<base-config cleartextTrafficPermitted="true">
<trust-anchors>
<certificates src="system" />
<certificates src="user" />
</trust-anchors>
</base-config>
</network-security-config>
@@ -1,63 +0,0 @@
package com.liketrek.trek;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertThrows;
import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
import java.security.KeyFactory;
import java.security.KeyPairGenerator;
import java.security.interfaces.ECPrivateKey;
import java.security.interfaces.ECPublicKey;
import java.security.spec.ECGenParameterSpec;
import java.security.spec.ECPrivateKeySpec;
import java.util.Base64;
import org.junit.Test;
/** The decryption against the worked example in RFC 8291, section 5. */
public class TrekWebPushTest {
private static byte[] b64(String value) {
return Base64.getUrlDecoder().decode(value);
}
private static TrekPushKeys rfcKeys() throws Exception {
KeyPairGenerator generator = KeyPairGenerator.getInstance("EC");
generator.initialize(new ECGenParameterSpec("secp256r1"));
ECPublicKey anyKey = (ECPublicKey) generator.generateKeyPair().getPublic();
BigInteger d = new BigInteger(1, b64("q1dXpw3UpT5VOmu_cf_v6ih07Aems3njxI-JWgLcM94"));
ECPrivateKey privateKey = (ECPrivateKey) KeyFactory.getInstance("EC").generatePrivate(new ECPrivateKeySpec(d, anyKey.getParams()));
return new TrekPushKeys(
privateKey,
b64("BCVxsr7N_eNgVRqvHtD0zTZsEc6-VV-JvLexhqUzORcxaOzi6-AYWXvTBHm4bjyPjs7Vd8pZGH6SRpkNtoIAiw4"),
b64("BTBZMqHH6r4Tts7J_aSIgg")
);
}
private static final String RECORD =
"DGv6ra1nlYgDCS1FRnbzlwAAEABBBP4z9KsN6nGRTbVYI_c7VJSPQTBtkgcy27mlmlMoZIIgDll6e3vCYLocInmYWAmS6TlzAC8wEqKK6PBru3jl7A_yl95bQpu6cVPTpK4Mqgkf1CXztLVBSt2Ks3oZwbuwXPXLWyouBWLVWGNWQexSgSxsj_Qulcy4a-fN";
@Test
public void decryptsTheRfcExample() throws Exception {
byte[] plain = TrekWebPush.decrypt(b64(RECORD), rfcKeys());
assertEquals("When I grow up, I want to be a watermelon", new String(plain, StandardCharsets.UTF_8));
}
@Test
public void refusesATamperedRecord() throws Exception {
byte[] record = b64(RECORD);
record[record.length - 1] ^= 1;
assertThrows(Exception.class, () -> TrekWebPush.decrypt(record, rfcKeys()));
}
@Test
public void writesThePublicKeyAsAFixedLengthPoint() throws Exception {
KeyPairGenerator generator = KeyPairGenerator.getInstance("EC");
generator.initialize(new ECGenParameterSpec("secp256r1"));
for (int i = 0; i < 20; i++) {
byte[] point = TrekPushKeys.uncompressed((ECPublicKey) generator.generateKeyPair().getPublic());
assertEquals(65, point.length);
assertEquals(4, point[0]);
}
}
}

Some files were not shown because too many files have changed in this diff Show More