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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

14 changes: 7 additions & 7 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,15 @@
"build:patch-db": "cd shared-libs/crates/patch-db/client && npm ci && npm run build",
"build:core": "cd shared-libs/ts-modules/start-core && make dist",
"build:deps": "node -e \"fs.rmSync('.angular/cache',{recursive:true,force:true})\" && npm run build:core && npm run build:patch-db",
"build:setup": "ng run setup-wizard:build",
"build:ui": "ng run ui:build",
"build:ui:dev": "ng run ui:build:development",
"build:tunnel": "ng run start-tunnel:build",
"build:wrt": "ng run start-wrt:build",
"build:wrt:demo": "node projects/start-wrt/web/build-config.js --mocks && ng run start-wrt:build && node -e \"fs.copyFileSync('projects/start-wrt/web/dist/startwrt/browser/index.html','projects/start-wrt/web/dist/startwrt/browser/404.html')\"",
"build:setup": "ng run setup-wizard:build --stats-json && node shared-libs/ts-modules/scripts/generate-immutable-assets.mjs projects/start-os/web/dist/raw/setup-wizard/stats.json projects/start-os/web/dist/raw/setup-wizard",
"build:ui": "ng run ui:build --stats-json && node shared-libs/ts-modules/scripts/generate-immutable-assets.mjs projects/start-os/web/dist/raw/ui/stats.json projects/start-os/web/dist/raw/ui",
"build:ui:dev": "ng run ui:build:development && node shared-libs/ts-modules/scripts/generate-immutable-assets.mjs --empty projects/start-os/web/dist/raw/ui",
"build:tunnel": "ng run start-tunnel:build --stats-json && node shared-libs/ts-modules/scripts/generate-immutable-assets.mjs projects/start-tunnel/web/dist/raw/start-tunnel/stats.json projects/start-tunnel/web/dist/raw/start-tunnel",
"build:wrt": "ng run start-wrt:build --stats-json && node shared-libs/ts-modules/scripts/generate-immutable-assets.mjs projects/start-wrt/web/dist/startwrt/stats.json projects/start-wrt/web/dist/startwrt/browser",
"build:wrt:demo": "node projects/start-wrt/web/build-config.js --mocks && npm run build:wrt && node -e \"fs.copyFileSync('projects/start-wrt/web/dist/startwrt/browser/index.html','projects/start-wrt/web/dist/startwrt/browser/404.html')\"",
"build:brochure": "ng run brochure-marketplace:build && node -e \"fs.copyFileSync('projects/brochure-marketplace/dist/raw/brochure-marketplace/index.html','projects/brochure-marketplace/dist/raw/brochure-marketplace/404.html')\"",
"build:all": "npm run build:deps && npm run build:setup && npm run build:ui",
"analyze:ui": "ng build ui --stats-json --named-chunks && npx -y @angular-experts/hawkeye dist/raw/ui/stats.json",
"analyze:ui": "ng build ui --stats-json --named-chunks && npx -y @angular-experts/hawkeye projects/start-os/web/dist/raw/ui/stats.json",
"start:setup": "npm run-script build-config && ng serve --project setup-wizard --host 0.0.0.0",
"start:ui": "npm run-script build-config && ng serve --project ui --host 0.0.0.0",
"start:tunnel": "ng serve --project start-tunnel --host 0.0.0.0",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -107,9 +107,8 @@ export class LiveApiService extends ApiService {

private async httpRequest<T>(opts: HttpOptions): Promise<T> {
const res = await this.http.httpRequest<T>(opts)
if (res.headers.get('Repr-Digest')) {
// verify
const digest = res.headers.get('Repr-Digest')!
if (res.headers.get('File-Digest')) {
const digest = res.headers.get('File-Digest')!
let data: Uint8Array
if (opts.responseType === 'arrayBuffer') {
data = Buffer.from(res.body as ArrayBuffer)
Expand All @@ -119,7 +118,7 @@ export class LiveApiService extends ApiService {
data = Buffer.from(await (res.body as Blob).arrayBuffer())
} else {
console.warn(
`could not verify Repr-Digest for responseType ${
`could not verify File-Digest for responseType ${
opts.responseType || 'json'
}`,
)
Expand Down
3 changes: 3 additions & 0 deletions projects/start-os/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -487,6 +487,9 @@ for the detail behind its highlights.
- **A service that mounts a dependency's files read-write fails to start when
that dependency is not installed**, naming the missing volume.

- **The Refresh Needed dialog offers a Refresh button in browser tabs.** Select
it to open the updated interface.

### Security

- **Service mount paths are validated and confined to their intended
Expand Down
2 changes: 2 additions & 0 deletions projects/start-os/docs/src/updating-startos.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@

1. When your server comes back, a notification welcomes you to the new version. Open it from **Notifications** — the bell in the menu — to read that release's notes again at any time.

A browser page left open during the update displays **Refresh Needed** when the server returns. Select **Refresh** to open the updated interface. If an installed StartOS app still shows the previous interface afterwards, remove and reinstall the app.

## Update by Re-flashing

If you are updating to an unreleased version of StartOS, or something went wrong with a UI update (very rare), it may be necessary to update StartOS by re-flashing. Follow the guide for [Installing StartOS](installing-startos.md).
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,10 @@ import { Component, inject } from '@angular/core'
import { toSignal } from '@angular/core/rxjs-interop'
import { SwUpdate } from '@angular/service-worker'
import { WA_WINDOW } from '@ng-web-apis/common'
import { i18nPipe } from '@start9labs/shared'
import { i18nPipe, TaskService } from '@start9labs/shared'
import { Version } from '@start9labs/start-core'
import { TuiResponsiveDialog } from '@taiga-ui/addon-mobile'
import { TuiButton } from '@taiga-ui/core'
import { TuiNotificationMiddleService } from '@taiga-ui/kit'
import { PatchDB } from 'patch-db-client'
import { distinctUntilChanged, map, merge, Subject } from 'rxjs'
import { ConfigService } from 'src/app/services/config.service'
Expand All @@ -30,53 +29,31 @@ import { DataModel } from 'src/app/services/patch-db/data-model'
| i18n
}}
</p>
<button
tuiButton
appearance="secondary"
style="float: right"
[tuiAppearanceFocus]="false"
(click)="pwaReload()"
>
{{ 'Refresh' | i18n }}
</button>
} @else {
{{
'Your user interface is cached and out of date. Hard refresh the page to get the latest UI.'
| i18n
}}
<ul>
<li>
<b>On Mac (Chrome/Firefox)</b>
: cmd + shift + R
</li>
<li>
<b>On Mac (Safari)</b>
: option + cmd + R, or hold option and choose View > Reload Page
from Origin
</li>
<li>
<b>On Linux/Windows</b>
: ctrl + shift + R
</li>
</ul>
<button
tuiButton
appearance="secondary"
style="float: right"
[tuiAppearanceFocus]="false"
(click)="dismiss$.next()"
>
{{ 'Ok' | i18n }}
</button>
<p>
{{
'StartOS has been updated, but this page is still running the previous interface. Refresh the page to get the latest version.'
| i18n
}}
</p>
}
<button
tuiButton
appearance="secondary"
style="float: right"
[tuiAppearanceFocus]="false"
(click)="reload()"
>
{{ 'Refresh' | i18n }}
</button>
</ng-template>
`,
imports: [TuiResponsiveDialog, TuiButton, i18nPipe],
})
export class RefreshAlertComponent {
private readonly win = inject(WA_WINDOW)
private readonly updates = inject(SwUpdate)
private readonly loader = inject(TuiNotificationMiddleService)
private readonly tasks = inject(TaskService)
private readonly version = Version.parse(inject(ConfigService).version)

readonly i18n = inject(i18nPipe)
Expand All @@ -99,15 +76,18 @@ export class RefreshAlertComponent {
},
)

async pwaReload() {
protected async reload(): Promise<void> {
try {
this.loader.open('Reloading PWA').subscribe()
// attempt to update to the latest client version available
await this.updates.activateUpdate()
} catch (e) {
console.error('Error activating update from service worker: ', e)
if (
this.updates.isEnabled &&
this.win.navigator.serviceWorker.controller !== null
) {
await this.tasks.run(async () => {
await this.updates.checkForUpdate()
await this.updates.activateUpdate()
}, 'Loading')
}
} finally {
// always reload, as this resolves most out of sync cases
this.win.location.reload()
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -806,9 +806,8 @@ export class LiveApiService extends ApiService {
}
}
const res = await this.http.httpRequest<T>(opts)
if (res.headers.get('Repr-Digest')) {
// verify
const digest = res.headers.get('Repr-Digest')!
if (res.headers.get('File-Digest')) {
const digest = res.headers.get('File-Digest')!
let data: Uint8Array
if (opts.responseType === 'arrayBuffer') {
data = Buffer.from(res.body as ArrayBuffer)
Expand All @@ -818,7 +817,7 @@ export class LiveApiService extends ApiService {
data = Buffer.from(await (res.body as Blob).arrayBuffer())
} else {
console.warn(
`could not verify Repr-Digest for responseType ${
`could not verify File-Digest for responseType ${
opts.responseType || 'json'
}`,
)
Expand All @@ -834,7 +833,7 @@ export class LiveApiService extends ApiService {
throw new Error('File digest mismatch.')
}
} else {
console.warn(`Unknown Repr-Digest algorithm ${alg}`)
console.warn(`Unknown File-Digest algorithm ${alg}`)
}
}
return res.body
Expand Down
3 changes: 3 additions & 0 deletions projects/start-tunnel/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
changed, a stale forward could keep sending a port to an old address until
the next reboot.

- **Open pages can load the current web interface after future updates.** Pages
opened on this release revalidate the interface when they reload.

## [1.3.0]

### Added
Expand Down
2 changes: 1 addition & 1 deletion projects/start-tunnel/build.mk
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ start-tunnel-install: target/$(RUST_ARCH)-unknown-linux-musl/$(PROFILE)/tunnelbo
$(call mkdir,$(DESTDIR)/usr/share/keyrings)
$(call cp,apt/start9.gpg,$(DESTDIR)/usr/share/keyrings/start9.gpg)

target/$(RUST_ARCH)-unknown-linux-musl/$(PROFILE)/tunnelbox: $(CORE_SRC) $(ENVIRONMENT_FILE) $(GIT_HASH_FILE) projects/start-tunnel/web/dist/static/start-tunnel/index.html projects/start-tunnel/build/build-tunnelbox.sh
target/$(RUST_ARCH)-unknown-linux-musl/$(PROFILE)/tunnelbox: $(CORE_SRC) $(ENVIRONMENT_FILE) $(GIT_HASH_FILE) projects/start-tunnel/web/dist/static/start-tunnel/immutable-assets.txt projects/start-tunnel/build/build-tunnelbox.sh
ARCH=$(ARCH) PROFILE=$(PROFILE) ./projects/start-tunnel/build/build-tunnelbox.sh

start-tunnel-deb: results/$(TUNNEL_BASENAME).deb
Expand Down
2 changes: 1 addition & 1 deletion projects/start-tunnel/docs/src/updating.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ Keep StartTunnel up to date using the web UI, CLI, or install script. Run the CL

## Web UI

Navigate to **Settings > Version > Check for Updates**.
Navigate to **Settings > Version > Check for Updates**. After the update finishes, reload any open StartTunnel page. When updating an interface that predates automatic revalidation, perform one hard refresh to load the current interface.

## CLI

Expand Down
3 changes: 2 additions & 1 deletion projects/start-tunnel/release-notes/1.3.1.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
**StartTunnel 1.3.1 clears stale port forwards when the daemon starts.**
**StartTunnel 1.3.1 clears stale port forwards when the daemon starts and keeps open browser pages current across future updates.**

## Highlights

- **Port forwards left behind by an earlier run of the daemon are cleared at startup**, so a crash or a WAN address change can no longer leave a port sending traffic to an old address until the next reboot.
- **Open pages can load the current web interface after future updates.** Pages opened on this release revalidate the interface when they reload. Perform one hard refresh after installing this release to replace an interface cached by an earlier version.
66 changes: 9 additions & 57 deletions projects/start-wrt/backend/ctrl/src/embedded_web.rs
Original file line number Diff line number Diff line change
@@ -1,48 +1,18 @@
use axum::body::Body;
use axum::http::{header, Request, Response, StatusCode};
use include_dir::{include_dir, Dir};
use startos::net::static_server::{is_ui_asset_immutable, is_ui_route};

static WEB_DIR: Dir<'static> = include_dir!("$CARGO_MANIFEST_DIR/../../web/dist/startwrt/browser");

// One ETag for the whole bundle: include_dir embeds no per-file metadata, and
// the dist only ever changes as a unit — with the firmware build.
const ETAG: &str = concat!("\"", env!("STARTWRT_GIT_HASH"), "\"");

/// Angular emits content-hashed top-level bundle files (`main-NUVV5TLQ.js`,
/// `chunk-C-f2EvjP.js`, `styles-XYUDF62Z.css`): a `-` plus 8 chars of
/// `[A-Za-z0-9_-]` before the extension. Their names change with their
/// content, so browsers may cache them forever. Everything else (index.html,
/// assets/) keeps a stable name across builds and must be revalidated.
fn is_content_hashed(path: &str) -> bool {
if path.contains('/') {
return false;
}
let Some(stem) = path
.strip_suffix(".js")
.or_else(|| path.strip_suffix(".css"))
else {
return false;
};
let bytes = stem.as_bytes();
bytes.len() > 9
&& bytes[bytes.len() - 9] == b'-'
&& bytes[bytes.len() - 8..]
.iter()
.all(|b| b.is_ascii_alphanumeric() || *b == b'-' || *b == b'_')
}

pub async fn serve_embedded(req: Request<Body>) -> Response<Body> {
let path = req.uri().path().trim_start_matches('/');
let path = if path.is_empty() { "index.html" } else { path };

let file = WEB_DIR.get_file(path).or_else(|| {
// SPA fallback: unknown extensionless paths are Angular routes and get
// index.html. Asset-like paths (anything with an extension, e.g. a
// hashed chunk from a previous firmware requested by a stale browser)
// must 404 instead — a 200 HTML body there breaks module loading and
// can get cached as the chunk.
let last_segment = path.rsplit('/').next().unwrap_or(path);
(!last_segment.contains('.'))
is_ui_route(path)
.then(|| WEB_DIR.get_file("index.html"))
.flatten()
});
Expand All @@ -54,11 +24,7 @@ pub async fn serve_embedded(req: Request<Body>) -> Response<Body> {
.unwrap();
};

// Content-hashed bundles are immutable; everything else is cached but
// revalidated on every load (a cheap 304 below), so a firmware update is
// picked up immediately. Browsers were previously left to heuristics here,
// which let them serve a stale pre-update UI indefinitely.
let cache_control = if is_content_hashed(&file.path().to_string_lossy()) {
let cache_control = if is_ui_asset_immutable(&WEB_DIR, file.path()) {
"public, max-age=31536000, immutable"
} else {
"no-cache"
Expand Down Expand Up @@ -94,20 +60,6 @@ pub async fn serve_embedded(req: Request<Body>) -> Response<Body> {
mod tests {
use super::*;

#[test]
fn content_hashed_detection() {
assert!(is_content_hashed("main-NUVV5TLQ.js"));
assert!(is_content_hashed("chunk-C-f2EvjP.js"));
assert!(is_content_hashed("chunk-Bu_0_vFc.js"));
assert!(is_content_hashed("styles-XYUDF62Z.css"));

assert!(!is_content_hashed("index.html"));
assert!(!is_content_hashed("favicon-96x96.png"));
assert!(!is_content_hashed("assets/fonts/font-AbCdEf12.css"));
assert!(!is_content_hashed("main.js"));
assert!(!is_content_hashed("chunk-C-f2EvjP.js.map"));
}

fn get(path: &str, if_none_match: Option<&str>) -> Response<Body> {
let mut req = Request::builder().uri(path);
if let Some(etag) = if_none_match {
Expand Down Expand Up @@ -161,16 +113,16 @@ mod tests {
}

#[test]
fn hashed_bundles_are_immutable() {
fn declared_assets_are_immutable() {
if !dist_embedded() {
return;
}
let main = WEB_DIR
let path = WEB_DIR
.files()
.map(|f| f.path().to_string_lossy().into_owned())
.find(|p| is_content_hashed(p))
.expect("built dist contains hashed bundles");
let res = get(&format!("/{main}"), None);
.find(|file| is_ui_asset_immutable(&WEB_DIR, file.path()))
.expect("the UI build declares an immutable asset")
.path();
let res = get(&format!("/{}", path.display()), None);
assert_eq!(res.status(), StatusCode::OK);
assert_eq!(
header_str(&res, header::CACHE_CONTROL),
Expand Down
4 changes: 2 additions & 2 deletions projects/start-wrt/build.mk
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ STARTWRT_RUST_ARCH := riscv64gc
STARTWRT_ARCH := riscv64

STARTWRT_BIN := target/$(STARTWRT_RUST_ARCH)-unknown-linux-musl/$(PROFILE)/startwrt
STARTWRT_WEB_DIST := $(STARTWRT_DIR)/web/dist/startwrt/browser/index.html
STARTWRT_WEB_DIST := $(STARTWRT_DIR)/web/dist/startwrt/browser/immutable-assets.txt
STARTWRT_WEB_CONFIG := $(STARTWRT_DIR)/web/config.json
STARTWRT_GIT_HASH_FILE := $(STARTWRT_DIR)/build/env/GIT_HASH.txt

Expand Down Expand Up @@ -71,7 +71,7 @@ $(STARTWRT_BIN): $(STARTWRT_RUST_SRC) $(STARTWRT_SHARED_RUST_SRC) Cargo.toml Car
# and .angular/.updated carry the shared libs + the @start9labs/start-core / patch-db
# client file: deps (defined in shared-libs/ts-modules/build.mk). $(STARTWRT_WEB_CONFIG)
# is start-wrt's own runtime config.json (separate from the root workspace config.json).
$(STARTWRT_WEB_DIST): $(STARTWRT_WEB_SRC) $(WEB_SHARED_SRC) .angular/.updated $(STARTWRT_WEB_CONFIG)
$(STARTWRT_WEB_DIST): $(STARTWRT_WEB_SRC) $(WEB_SHARED_SRC) $(IMMUTABLE_ASSETS_GENERATOR) .angular/.updated $(STARTWRT_WEB_CONFIG)
npm --prefix . run build:wrt
touch $(STARTWRT_WEB_DIST)

Expand Down
1 change: 1 addition & 0 deletions shared-libs/crates/start-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,7 @@ serde_toml = { package = "toml", version = "0.9" }

[dev-dependencies]
clap_mangen = "0.2.33"
tempfile = "3.14.0"
tokio = { version = "1.38.1", features = ["test-util"] }

[target.'cfg(target_os = "linux")'.dependencies]
Expand Down
Loading
Loading