mirror of
https://github.com/MervinPraison/PraisonAI.git
synced 2026-11-07 12:47:55 -10:00
fix(mobile): the app could not reach an engine, type into itself, or parse on its own WebView floor (#4678)
* fix(mobile): the app could not reach an engine, type into itself, or parse on its own WebView floor Three agents audited the package against one question -- what stops this being an installable iOS/Android app. Every finding below was reproduced before it was fixed, and every fix is re-run against the mutation that reverts it. THE APP COULD NOT REACH ANY ENGINE. `connect-src 'self' ipc: http://ipc.localhost` permitted no engine address at all, while `baseUrl` is a user setting that accepts any host. Under Tauri the HttpPort is still browser `fetch`, so a webview enforces connect-src on it: every request was blocked before a packet left, including the shipped default. Proved by serving dist/ twice, once with the shipped CSP and once without -- "Refused to connect because it violates the document's Content Security Policy". The desktop app already carried the loopback entries; mobile also needs https:, because on a phone the engine is across a network. script-src stays closed, and a test pins that it does. THE COMPOSER SAT UNDER THE KEYBOARD. `createTauriShell` had `keyboardHeightPx = 0` whose only writer was the native `keyboard-height` event -- and nothing emits it: `src-tauri/src/lib.rs`'s `on_window_event` is an empty closure, left unwired until the mobile targets are initialised. Since `app.css` pins `#root` to `position: fixed; inset: 0`, the layout viewport does not shrink either, so the composer stayed under the keyboard from the first tap. The WEB shell had this right all along and nothing compared the two. It now seeds and tracks from `visualViewport`, reusing the web shell's `readKeyboardHeight`, and the native event takes over the moment it exists -- one writer at a time, so they cannot race. Found while testing it: the fake `visualViewport` had `addEventListener` and no `removeEventListener`, so no adapter that unsubscribes could be exercised there. The fake now models both. THE BUNDLE COULD NOT PARSE ON THE FLOOR IT DECLARES. `TARGETS` said `chrome108` while tauri.conf.json declares `minSdkVersion: 26` -- Android 8.0, WebView ~Chrome 58 -- and the output carried optional chaining, nullish coalescing and logical assignment. index.html loads it as a module, so that is a parse error: the body never evaluates, and `installCrashHandler` is imported by the same module so it never installs either. A blank white screen with no error surface. Nothing linked the two numbers; they are derived now, and tested against each other. Cost: 5.2kB of a 400kB budget. EVERY SCREEN RENDERED AT ONCE. `.screen { display: flex }` is an author declaration and beats the UA `[hidden] { display: none }`, so `el.hidden = true` in mount.ts did nothing. Measured in headless Chrome: after tapping Settings the chat screen was still on screen at half height with the settings list stacked underneath, halving again on every navigation. ui/src/screens.ts was correct throughout; only the stylesheet broke it. THE COMPOSER'S SAFE AREA WAS COUNTED TWICE, and its gutter lost. `geometryOf` already returns `max(keyboard, insets.bottom)` -- insets.ts calls the sum "the single most recognisable 'web page in a box' tell there is" -- and app.css added the inset back: measured 76px where 42px is correct. Separately main.ts wrote `padding-inline` inline, beating the stylesheet's gutter, so at 320px the textarea sat flush at x = 0 while everything else kept 12px. THE GATE PASSED BUNDLES THAT CANNOT LOAD. `problems` was built from three checks -- static Node builtins, top-level process.env, size -- so a bare import esbuild could not RESOLVE was reported shippable. Bundling praisonai-ts's own webview entry through it reported 80.5kB, 0 problems and `shippable: true` while silently leaving out openai, ai, four @ai-sdk packages, chalk, ora, boxen, figlet and cli-table3. That loads on a laptop with node_modules beside it and dies on a phone. CI HAD NEVER COMPILED FOR A PHONE. Both `rust shell` legs are host targets, so `#[cfg_attr(mobile, tauri::mobile_entry_point)]` -- the attribute generating the Android JNI and iOS start_app symbols -- expanded zero times. A `cross-check` job now runs `cargo check --lib` for aarch64-linux-android and aarch64-apple-ios. It needs no NDK and no Xcode project; verified locally at 24s for the android leg. The iOS leg cannot be verified here (Command Line Tools only, no iOS SDK) and is proved by this PR's own CI. AND THE SECOND RELEASE WOULD HAVE BEEN REJECTED. tauri.conf.json is hardcoded at 0.1.0 with nothing to change it -- the same root cause as #4527, but worse than a wrong label: Tauri derives Android versionCode and iOS CFBundleVersion from it, and both stores refuse a repeat. tools/set-release-version.mjs takes the tag and a monotonic build number, following the single-source rule the desktop package settled on. Also: Cargo.lock is committed (this is an application; `tauri = "2"` resolved independently on every machine), @tauri-apps/cli is a devDependency so a mobile build can be attempted at all, and the generated mobile ACL schemas land beside the desktop ones. 1326 tests on Node 22.23 and 24.12; cargo test green; cargo check green for aarch64-linux-android. Refs #4673, #4640 * fix(mobile): sync package-lock.json with the @tauri-apps/cli devDependency CI failed in 11-21s across four jobs: adding the dependency to package.json without regenerating the lockfile makes `npm ci` refuse, and every job starts with it. Reproduced locally by running `npm ci` against a copy of the two files, and verified fixed the same way -- 7 packages added. The lockfile was updated with --package-lock-only so node_modules was not touched; this worktree symlinks another one's. * fix(mobile): sync lock file for @tauri-apps/cli; build floor-parse check in-memory All 7 CI jobs failed at `npm ci` because package.json added @tauri-apps/cli but package-lock.json was never regenerated (EUSAGE, lock out of sync). Regenerated the lock file so `npm ci` is in sync across check, rust shell, cross-check, and upstream-parity legs. Separately, tools/bundle-target.test.mjs's "shipped bundle" assertion read a pre-built dist/app.js, but the `check` job runs `npm test` without a prior `npm run build`, so it would fail once the lock file was fixed. Build the same entry in-memory through the same bundle() at the same TARGETS instead -- same output, no build-order dependency. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Mervin Praison <[email protected]> --------- Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com> Co-authored-by: Mervin Praison <[email protected]>
This commit is contained in:
20 files changed
+9982
-9
No files matched your search
@@ -146,3 +146,58 @@ jobs:
|
||||
run: cargo test
|
||||
- name: Clippy
|
||||
run: cargo clippy --all-targets -- -D warnings
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# The mobile targets, which nothing compiled.
|
||||
#
|
||||
# `src/lib.rs` is `#[cfg_attr(mobile, tauri::mobile_entry_point)]`, and
|
||||
# `tauri_build::build()` emits `cargo:rustc-cfg=mobile` only for android and
|
||||
# ios targets. Both legs of `rust shell` above are HOST targets, so that
|
||||
# attribute -- the one that generates the Android JNI symbol and the iOS
|
||||
# start_app symbol -- expanded exactly zero times in CI, and neither did the
|
||||
# `#[cfg(target_os = "android")]` / `ios` arms of `commands.rs`. The job
|
||||
# above says it covers cfg mistakes; it covers the ones in `shell/`, not
|
||||
# these.
|
||||
#
|
||||
# `cargo check`, not `build`: it type-checks and macro-expands under the
|
||||
# mobile cfg, which is the thing that was never done, and needs no NDK and no
|
||||
# Xcode project. Verified at 18s for the android target.
|
||||
# ------------------------------------------------------------------
|
||||
cross-check:
|
||||
name: cross-check (${{ matrix.target }})
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- target: aarch64-linux-android
|
||||
os: ubuntu-22.04
|
||||
- target: aarch64-apple-ios
|
||||
os: macos-15
|
||||
defaults:
|
||||
run:
|
||||
working-directory: src/praisonai-mobile/src-tauri
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22.18'
|
||||
cache: npm
|
||||
cache-dependency-path: src/praisonai-mobile/package-lock.json
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: src/praisonai-mobile/src-tauri
|
||||
key: ${{ matrix.target }}
|
||||
# generate_context! expands frontendDist at COMPILE time and dist/ is
|
||||
# gitignored, so the crate cannot be checked without this -- the same
|
||||
# reason the job above needs it.
|
||||
- name: Build the webview (creates ../dist for generate_context!)
|
||||
working-directory: src/praisonai-mobile
|
||||
run: |
|
||||
npm ci
|
||||
npm run build
|
||||
- name: Does the crate still expand and type-check for a phone?
|
||||
run: cargo check --lib --target ${{ matrix.target }}
|
||||
@@ -1395,3 +1395,51 @@ test("a FULLY present Tauri global is treated as present -- the pair", () => {
|
||||
});
|
||||
assert.equal(both.isPresent(), true);
|
||||
});
|
||||
|
||||
// ---- the keyboard, on the shell that actually runs on a phone ---------------
|
||||
|
||||
test("the TAURI shell reports the keyboard height, without any native event", async () => {
|
||||
// `keyboardHeightPx` was a hard 0 whose only writer was the native
|
||||
// `keyboard-height` event -- and nothing emits it: src-tauri/src/lib.rs's
|
||||
// `on_window_event` is an empty closure, left unwired until the mobile
|
||||
// targets are initialised. On a device --keyboard-height stayed 0 forever,
|
||||
// and app.css pins #root to `position: fixed; inset: 0` so the layout
|
||||
// viewport does not shrink either. The composer sat under the keyboard the
|
||||
// moment anyone tapped it -- the first thing anyone does with the app.
|
||||
//
|
||||
// The WEB shell had this right all along. Nothing compared the two.
|
||||
const window = createFakeWindow();
|
||||
const probe = probeBridge();
|
||||
const shell = createTauriShell({ bridge: probe.bridge, view: window.window });
|
||||
await Promise.resolve();
|
||||
|
||||
assert.equal(shell.keyboardHeightPx, 0, "no keyboard to begin with");
|
||||
|
||||
const heights: number[] = [];
|
||||
shell.onKeyboardHeightChanged((px) => heights.push(px));
|
||||
window.setKeyboardHeight(320);
|
||||
|
||||
assert.equal(shell.keyboardHeightPx, 320, "the shell must see the keyboard open");
|
||||
assert.deepEqual(heights, [320], "and tell its subscribers exactly once");
|
||||
|
||||
window.setKeyboardHeight(0);
|
||||
assert.equal(shell.keyboardHeightPx, 0, "and see it close");
|
||||
assert.deepEqual(heights, [320, 0]);
|
||||
});
|
||||
|
||||
test("a native keyboard height takes over from the viewport reading", async () => {
|
||||
// The native event fires THROUGH the transition; the viewport only at its
|
||||
// ends. Native wins where it exists -- but two live writers would race on
|
||||
// every keyboard animation, so the viewport listener is dropped once a
|
||||
// native height arrives.
|
||||
const window = createFakeWindow();
|
||||
const probe = probeBridge();
|
||||
const shell = createTauriShell({ bridge: probe.bridge, view: window.window });
|
||||
await Promise.resolve();
|
||||
|
||||
probe.emit("keyboard-height", 291);
|
||||
assert.equal(shell.keyboardHeightPx, 291, "native is authoritative");
|
||||
|
||||
window.setKeyboardHeight(320);
|
||||
assert.equal(shell.keyboardHeightPx, 291, "the viewport must not fight the native source");
|
||||
});
|
||||
@@ -44,6 +44,11 @@ import type {
|
||||
Unsubscribe,
|
||||
} from "../../../core/src/ports/shell.ts";
|
||||
import { createTauriBridge, type TauriBridge } from "./bridge.ts";
|
||||
// The keyboard reading is identical on both platforms and already reasoned
|
||||
// through in the web shell (pinch-zoom vs a real keyboard, overscroll, the
|
||||
// offsetTop term). Importing it keeps one implementation rather than a copy
|
||||
// that drifts.
|
||||
import { readKeyboardHeight } from "../web/shell.ts";
|
||||
|
||||
/**
|
||||
* A ShellPort plus one observable.
|
||||
@@ -217,6 +222,9 @@ export interface TauriShellDeps {
|
||||
readonly bridge?: TauriBridge;
|
||||
/** Defaults to the DOM, or to nothing when there is no DOM. */
|
||||
readonly insetSource?: InsetSource;
|
||||
/** The window whose `visualViewport` reports the keyboard. Defaults to the
|
||||
* real one; injected in tests so the path runs without a phone. */
|
||||
readonly view?: Window;
|
||||
}
|
||||
|
||||
export function createTauriShell(deps: TauriShellDeps = {}): TauriShell {
|
||||
@@ -228,7 +236,24 @@ export function createTauriShell(deps: TauriShellDeps = {}): TauriShell {
|
||||
|
||||
const insetSubs = new Set<(insets: SafeAreaInsets) => void>();
|
||||
const keyboardSubs = new Set<(heightPx: number) => void>();
|
||||
let keyboardHeightPx = 0;
|
||||
|
||||
// Seeded and driven from `visualViewport`, exactly as the web shell does.
|
||||
//
|
||||
// This was a hard `0` whose only writer was the native `keyboard-height`
|
||||
// event -- and nothing emits that event: `src-tauri/src/lib.rs`'s
|
||||
// `on_window_event` is an empty closure, left unwired until the mobile
|
||||
// targets are initialised. So on a device `--keyboard-height` stayed 0
|
||||
// forever, and because `app.css` pins `#root` to `position: fixed; inset: 0`
|
||||
// the layout viewport does not shrink either. The composer sat underneath
|
||||
// the keyboard the instant anyone tapped it -- the first thing anyone does.
|
||||
//
|
||||
// WKWebView and Android WebView both implement `visualViewport`, so the web
|
||||
// shell's reading works here and needs no native code. The native event
|
||||
// stays wired below and overrides this the moment it starts arriving: it is
|
||||
// the better source, but it must not be the ONLY source while it does not
|
||||
// exist.
|
||||
const view = deps.view ?? (globalThis as { window?: Window }).window;
|
||||
let keyboardHeightPx = view === undefined ? 0 : readKeyboardHeight(view);
|
||||
const lifecycleSubs = new Set<(phase: LifecyclePhase) => void>();
|
||||
// An ARRAY, not a Set: back handlers are a stack and the most recently
|
||||
// registered gets first refusal. A Set has no defined order, so a modal
|
||||
@@ -302,8 +327,34 @@ export function createTauriShell(deps: TauriShellDeps = {}): TauriShell {
|
||||
publishInsets(coerceInsets(payload) ?? readInsets(source));
|
||||
});
|
||||
|
||||
// The web path, live. `visualViewport` fires `resize` and `scroll` as the
|
||||
// keyboard animates, which is what makes the composer track it rather than
|
||||
// jump once at the end. Removed the moment a native height arrives, so the
|
||||
// two sources never fight -- native is authoritative where it exists.
|
||||
let viewportOff: Unsubscribe | null = null;
|
||||
if (view?.visualViewport !== undefined && view.visualViewport !== null) {
|
||||
const viewport = view.visualViewport;
|
||||
const onViewport = (): void => {
|
||||
const height = readKeyboardHeight(view);
|
||||
if (height === keyboardHeightPx) return;
|
||||
keyboardHeightPx = height;
|
||||
for (const cb of keyboardSubs) cb(height);
|
||||
};
|
||||
viewport.addEventListener("resize", onViewport);
|
||||
viewport.addEventListener("scroll", onViewport);
|
||||
viewportOff = () => {
|
||||
viewport.removeEventListener("resize", onViewport);
|
||||
viewport.removeEventListener("scroll", onViewport);
|
||||
};
|
||||
}
|
||||
|
||||
attach(EVENTS.keyboard, (payload) => {
|
||||
const height = coerceKeyboardHeight(payload);
|
||||
// A native height has arrived, so stop reading the viewport: the native
|
||||
// source fires through the transition and the viewport one only at its
|
||||
// ends, and two writers would race on every keyboard animation.
|
||||
viewportOff?.();
|
||||
viewportOff = null;
|
||||
// Snapshot before notifying, per the port's ordering rule.
|
||||
keyboardHeightPx = height;
|
||||
// Unconditional. Fires through the transition, not just at its end, and
|
||||
|
||||
@@ -48,6 +48,13 @@ export function createFakeWindow(): FakeWindow {
|
||||
set.add(cb);
|
||||
map.set(type, set);
|
||||
};
|
||||
/** The other half of `on`. A real `visualViewport` has it, and this fake did
|
||||
* not -- so a listener could be added and never removed, and any adapter
|
||||
* that unsubscribes threw here instead of being exercised. */
|
||||
const off = (map: Map<string, Set<(e?: unknown) => void>>) =>
|
||||
(type: string, cb: (e?: unknown) => void) => {
|
||||
map.get(type)?.delete(cb);
|
||||
};
|
||||
const fire = (map: Map<string, Set<(e?: unknown) => void>>, type: string) => {
|
||||
for (const cb of [...(map.get(type) ?? [])]) cb();
|
||||
};
|
||||
@@ -73,6 +80,7 @@ export function createFakeWindow(): FakeWindow {
|
||||
},
|
||||
offsetTop: 0,
|
||||
addEventListener: on(vpListeners),
|
||||
removeEventListener: off(vpListeners),
|
||||
};
|
||||
},
|
||||
document: {
|
||||
|
||||
@@ -69,6 +69,14 @@ html, body {
|
||||
}
|
||||
|
||||
.screen { display: flex; flex-direction: column; min-height: 0; flex: 1; }
|
||||
/* An AUTHOR `display` beats the user-agent's `[hidden] { display: none }`, so
|
||||
the line above silently defeated `el.hidden = true` in app/src/mount.ts and
|
||||
every screen rendered at once -- measured in headless Chrome: after tapping
|
||||
Settings the chat screen was still on screen at half height with the
|
||||
settings list stacked underneath, and each navigation halved the viewport
|
||||
again. ui/src/screens.ts's mount/hide/unmount logic was correct throughout;
|
||||
only this stylesheet broke it. */
|
||||
.screen[hidden] { display: none; }
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
@@ -134,7 +142,14 @@ html, body {
|
||||
/* main.ts sets this from the shell's live keyboard height. It is a padding
|
||||
rather than a transform so the transcript's scroll height shrinks with it
|
||||
and the last message stays reachable. */
|
||||
padding-bottom: calc(var(--keyboard-height, 0px) + var(--safe-area-inset-bottom) + .5rem);
|
||||
/* NOT `+ var(--safe-area-inset-bottom)`. `--keyboard-height` is written from
|
||||
`geometryOf`, which is already `max(keyboard, insets.bottom)` -- adding the
|
||||
inset back double-counts it. ui/src/layout/insets.ts calls that sum "the
|
||||
single most recognisable 'web page in a box' tell there is", and it was
|
||||
reintroduced here: measured 76px of padding with a 34px home indicator
|
||||
where 42px is correct, and a keyboard's height of dead space above the
|
||||
keyboard once one opens. */
|
||||
padding-bottom: calc(var(--keyboard-height, 0px) + .5rem);
|
||||
}
|
||||
.composer textarea {
|
||||
flex: 1;
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* The stylesheet, checked against what the app does to it.
|
||||
*
|
||||
* `app.css` is not typechecked, not bundled through the gate, and not covered
|
||||
* by any test -- so three rules that decide whether the app is usable on a
|
||||
* phone were unasserted. Each of these was a measured defect, not a hypothetical.
|
||||
*/
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
const raw = readFileSync(join(import.meta.dirname, "../app.css"), "utf8");
|
||||
/** Comments explain the rules and sometimes name the very token a rule must
|
||||
* NOT contain, so they are stripped before matching. */
|
||||
const css = raw.replace(/\/\*[\s\S]*?\*\//g, "");
|
||||
const main = readFileSync(join(import.meta.dirname, "main.ts"), "utf8")
|
||||
.replace(/\/\/.*$/gm, "");
|
||||
|
||||
test("a hidden screen is actually hidden", () => {
|
||||
// `.screen { display: flex }` is an AUTHOR declaration and beats the user
|
||||
// agent's `[hidden] { display: none }`, so `el.hidden = true` in mount.ts did
|
||||
// nothing. Measured in headless Chrome: after tapping Settings the chat
|
||||
// screen was still on screen at half height with the settings list stacked
|
||||
// under it, and every navigation halved the viewport again.
|
||||
assert.match(css, /\.screen\[hidden\]\s*\{[^}]*display:\s*none/, "app.css must hide a hidden screen");
|
||||
});
|
||||
|
||||
test("the composer does not add the bottom inset twice", () => {
|
||||
// `--keyboard-height` is written from `geometryOf`, which is already
|
||||
// `max(keyboard, insets.bottom)`. Adding `--safe-area-inset-bottom` back is
|
||||
// the `bottom + keyboard` sum that ui/src/layout/insets.ts calls "the single
|
||||
// most recognisable 'web page in a box' tell there is".
|
||||
// Each declaration taken up to its semicolon, rather than by a paren-matching
|
||||
// regex: `calc(var(a) + var(b))` has two nested groups and a `[^)]*` pattern
|
||||
// stops at the first `)`, which is why the first version of this test passed
|
||||
// against the bug it was written for.
|
||||
const declarations = [...css.matchAll(/padding-bottom\s*:([^;]*);/g)].map((m) => m[1] ?? "");
|
||||
const keyboardDecls = declarations.filter((d) => d.includes("--keyboard-height"));
|
||||
assert.ok(keyboardDecls.length > 0, "the composer must follow the keyboard at all");
|
||||
for (const d of keyboardDecls) {
|
||||
assert.ok(
|
||||
!d.includes("--safe-area-inset-bottom"),
|
||||
`--keyboard-height is already max(keyboard, inset); adding the inset double-counts it:${d}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("the composer keeps a gutter when the inset is zero", () => {
|
||||
// main.ts writes padding-inline INLINE, which beats the stylesheet. Writing
|
||||
// the bare inset put the textarea flush to the screen edge at a 320px
|
||||
// viewport while every other element kept its 12px gutter.
|
||||
const starts = /padding-inline-start",\s*`([^`]*)`/.exec(main)?.[1] ?? "";
|
||||
const ends = /padding-inline-end",\s*`([^`]*)`/.exec(main)?.[1] ?? "";
|
||||
for (const [side, value] of [["start", starts], ["end", ends]] as const) {
|
||||
assert.match(value, /\+\s*\.75rem/, `the ${side} gutter must survive a zero inset: ${value}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("the viewport bridge the safe areas depend on is intact -- the pair", () => {
|
||||
// The three rules above are all about safe-area handling; if the bridge that
|
||||
// supplies the values were removed they would all pass while measuring zero.
|
||||
assert.match(css, /--safe-area-inset-bottom:\s*env\(safe-area-inset-bottom/);
|
||||
assert.match(css, /--safe-area-inset-top:\s*env\(safe-area-inset-top/);
|
||||
});
|
||||
@@ -673,8 +673,13 @@ export async function mount(deps: MountDeps): Promise<App | null> {
|
||||
screen.style.setProperty("--keyboard-height", `${geometry.composerBottomPx}px`);
|
||||
screen.style.setProperty("--inset-top", `${geometry.scrollTopPx}px`);
|
||||
const logical = logicalInsets(bundle.direction, geometry.composerLeftPx, geometry.composerRightPx);
|
||||
composer.style.setProperty("padding-inline-start", `${logical.startPx}px`);
|
||||
composer.style.setProperty("padding-inline-end", `${logical.endPx}px`);
|
||||
// The GUTTER is added here, not left to the stylesheet. An inline style
|
||||
// beats `app.css`'s `calc(var(--safe-area-inset-left) + .75rem)`, so
|
||||
// writing the bare inset put the composer flush against the screen edge
|
||||
// while the topbar and transcript kept their gutter -- measured at a 320px
|
||||
// viewport with no side insets: the textarea's left edge sat at x = 0.
|
||||
composer.style.setProperty("padding-inline-start", `calc(${logical.startPx}px + .75rem)`);
|
||||
composer.style.setProperty("padding-inline-end", `calc(${logical.endPx}px + .75rem)`);
|
||||
};
|
||||
applyGeometry();
|
||||
platform.shell.onInsetsChanged(applyGeometry);
|
||||
|
||||
Generated
+218
@@ -9,6 +9,7 @@
|
||||
"version": "0.1.0",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2",
|
||||
"@types/node": "^22.20.1",
|
||||
"esbuild": "^0.25.0",
|
||||
"typescript": "^5.7.3"
|
||||
@@ -459,6 +460,223 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli": {
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.11.4.tgz",
|
||||
"integrity": "sha512-R8xGtMpwyetawSqm9kYOuMmEqkhUbvcUy8n0aNXIxollKBLESUu5f4Fx+64hgASYm1H+jSWq6jCW6zqTnH6hqQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"bin": {
|
||||
"tauri": "tauri.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/tauri"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@tauri-apps/cli-darwin-arm64": "2.11.4",
|
||||
"@tauri-apps/cli-darwin-x64": "2.11.4",
|
||||
"@tauri-apps/cli-linux-arm-gnueabihf": "2.11.4",
|
||||
"@tauri-apps/cli-linux-arm64-gnu": "2.11.4",
|
||||
"@tauri-apps/cli-linux-arm64-musl": "2.11.4",
|
||||
"@tauri-apps/cli-linux-riscv64-gnu": "2.11.4",
|
||||
"@tauri-apps/cli-linux-x64-gnu": "2.11.4",
|
||||
"@tauri-apps/cli-linux-x64-musl": "2.11.4",
|
||||
"@tauri-apps/cli-win32-arm64-msvc": "2.11.4",
|
||||
"@tauri-apps/cli-win32-ia32-msvc": "2.11.4",
|
||||
"@tauri-apps/cli-win32-x64-msvc": "2.11.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-darwin-arm64": {
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.11.4.tgz",
|
||||
"integrity": "sha512-1ryOF3ZhpZ/nemHV5zVwBQBz9jDGKmKPvWPADOhc83ig0P4bMc2iER4NbC6r9sjeIZ6RVQ4g3RZIYvezhcl4TQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-darwin-x64": {
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.11.4.tgz",
|
||||
"integrity": "sha512-uFsGQAAfuyz1k/yGLmkWfkBlgKAqZfxqlHmLWx81QU27RJWfmbNHCIq8T8w1e+VClleIuZUjpHWfoE4E3DLo3A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-linux-arm-gnueabihf": {
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.11.4.tgz",
|
||||
"integrity": "sha512-IaHZn5CdBL21oUmjiVOS1ctw6Ip1O0pjp70FwOWmYz1myWe0SY96ZIj2FYf7pT0m8bI2h/hrs5ZbEXXh44/MkQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-linux-arm64-gnu": {
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.11.4.tgz",
|
||||
"integrity": "sha512-N41/ukTRVe6XSuUTESuFdGeOW2i7k62tK+6gHK5Kd5/q5RPvvi19GaWAVPPb9u95HSGmTChSolBfzynUsssFaA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-linux-arm64-musl": {
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.11.4.tgz",
|
||||
"integrity": "sha512-v277UnT/fB64xAfSroL5N3Km3tLmvATWqJJw/wRI+g6o+HkeD0slyE7gOhNs1MbjE41R7bQOTxMVoL3aomUJmw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-linux-riscv64-gnu": {
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.11.4.tgz",
|
||||
"integrity": "sha512-qqgNkQ2u1yZHxjhxsZaxUtRDW8dIqIYm33rx/mzwQv0SfY9x1B+iraj8vWeFiXjjSVVhEMepXSOts1TqPzvXNQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-linux-x64-gnu": {
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.11.4.tgz",
|
||||
"integrity": "sha512-2VRNWl84FOH0m2giiDkO2h0QXlcMJeX+zJDpI5kDIQAx6s+geF3v48F4DXfJez4GS/FdoDGnPnw1C2iYGbQ7bQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-linux-x64-musl": {
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.11.4.tgz",
|
||||
"integrity": "sha512-o9GyhYor/nc7xarmwDE3ka2szuW3uuZzXjHWh64Q8YX5AtSgxdQkFWzrY4O8KiGtVNvFBI14H3Q49Qj5TOIP/A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-win32-arm64-msvc": {
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.11.4.tgz",
|
||||
"integrity": "sha512-ld5Ehb598m0VkYyylRPNeCFsBe/km0jxis6KgMpl3IGY6I/i1RwQXO05I1AsXUXO2WC6AvB/Lw4qTf/asiuEiQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-win32-ia32-msvc": {
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.11.4.tgz",
|
||||
"integrity": "sha512-12Hxi0XX/H5VFxO/bGgHkFWhml9VMgEOu9CidjeCeTNQ1l6fpUlbiGgSP7CLI3PFtW9/FfbeHieZ+kyWK5H7CA==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-win32-x64-msvc": {
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.11.4.tgz",
|
||||
"integrity": "sha512-+vDiqBIU5dMISg/wNvX3sF+ZHfgJGJ5T0AcO+EHNXV9GGAG+P5fzodlDXD3QdKCRgZxMoCm5PPvj3BqLNjBthw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "22.20.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz",
|
||||
|
||||
@@ -17,9 +17,11 @@
|
||||
"check": "npm run typecheck && npm run boundaries && npm run test",
|
||||
"check:upstream": "node tools/check-upstream-parity.mjs",
|
||||
"build:webview": "node tools/build-webview.mjs",
|
||||
"test:rust": "cd src-tauri && cargo test"
|
||||
"test:rust": "cd src-tauri && cargo test",
|
||||
"tauri": "tauri"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2",
|
||||
"@types/node": "^22.20.1",
|
||||
"esbuild": "^0.25.0",
|
||||
"typescript": "^5.7.3"
|
||||
|
||||
+4513
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -18,7 +18,7 @@
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self' ipc: http://ipc.localhost"
|
||||
"csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self' ipc: http://ipc.localhost https: http://127.0.0.1:* http://localhost:*"
|
||||
},
|
||||
"withGlobalTauri": true
|
||||
},
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* The esbuild target, checked against the platform minimums the app declares.
|
||||
*
|
||||
* These are two numbers in two files that must agree, and nothing linked them.
|
||||
* `tools/bundle.mjs` said `chrome108` while `tauri.conf.json` declared
|
||||
* `minSdkVersion: 26` -- Android 8.0, whose factory WebView is Chrome ~58.
|
||||
*
|
||||
* The failure is silent and total: `index.html` loads the bundle as
|
||||
* `<script type="module">`, so post-Chrome-58 syntax is a PARSE error and the
|
||||
* module body never runs. `installCrashHandler` is imported by that same
|
||||
* module, so it never installs -- a blank white screen, no error surface, no
|
||||
* telemetry, on exactly the devices (AOSP, Play-less, long-offline) that a
|
||||
* WebView floor exists to protect.
|
||||
*/
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync, mkdtempSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { bundle, TARGETS, ANDROID_WEBVIEW_FLOOR } from "./bundle.mjs";
|
||||
|
||||
const conf = JSON.parse(
|
||||
readFileSync(join(import.meta.dirname, "../src-tauri/tauri.conf.json"), "utf8"),
|
||||
);
|
||||
const target = (prefix) => TARGETS.find((t) => t.startsWith(prefix));
|
||||
|
||||
test("the Chrome target matches the Android minSdkVersion the app declares", () => {
|
||||
const minSdk = conf.bundle.android.minSdkVersion;
|
||||
const required = ANDROID_WEBVIEW_FLOOR[minSdk];
|
||||
assert.ok(
|
||||
required !== undefined,
|
||||
`minSdkVersion ${minSdk} has no WebView floor recorded in bundle.mjs -- add one rather than guessing`,
|
||||
);
|
||||
assert.equal(
|
||||
target("chrome"),
|
||||
required,
|
||||
`minSdkVersion ${minSdk} ships a WebView at ${required}; the bundle targets ${target("chrome")}`,
|
||||
);
|
||||
});
|
||||
|
||||
test("the Safari target matches the iOS minimum the app declares", () => {
|
||||
// This pair already agreed. Asserted so it keeps agreeing, and so the test
|
||||
// above is not the only thing holding the relationship.
|
||||
const major = Number(String(conf.bundle.iOS.minimumSystemVersion).split(".")[0]);
|
||||
assert.equal(target("safari"), `safari${major}`, "iOS 16 ships Safari 16");
|
||||
});
|
||||
|
||||
test("the shipped bundle contains no syntax the floor cannot parse", async () => {
|
||||
// The assertion that would actually have caught it. Optional chaining and
|
||||
// nullish coalescing are Chrome 80; logical assignment is Chrome 85. Any of
|
||||
// them at a chrome58 floor is a blank screen.
|
||||
//
|
||||
// Built here through the same bundle() at the same TARGETS the ship path
|
||||
// uses, rather than read from a pre-built dist/app.js: the `check` job runs
|
||||
// `npm test` without a prior `npm run build`, so a disk read would be a test
|
||||
// that can only pass by accident of build order. Same entry, same targets,
|
||||
// same output -- with no ordering dependency.
|
||||
const out = join(mkdtempSync(join(tmpdir(), "floor-")), "app.js");
|
||||
const { code } = await bundle({
|
||||
entry: join(import.meta.dirname, "../app/src/main.ts"),
|
||||
outfile: out,
|
||||
write: false,
|
||||
});
|
||||
for (const [name, pattern] of [
|
||||
["optional chaining", /\?\./],
|
||||
["nullish coalescing", /\?\?[^=]/],
|
||||
["logical assignment", /\?\?=|\|\|=|&&=/],
|
||||
]) {
|
||||
assert.doesNotMatch(code, pattern, `${name} is post-Chrome-58 and will not parse on the floor`);
|
||||
}
|
||||
});
|
||||
@@ -21,6 +21,22 @@ import * as esbuild from "esbuild";
|
||||
import { readFile } from "node:fs/promises";
|
||||
|
||||
/** Node builtins that must never survive into a webview bundle. */
|
||||
/** esbuild's own metafile markers, which are not packages. */
|
||||
const RUNTIME_MARKERS = new Set(["<runtime>"]);
|
||||
|
||||
/** Every Node builtin, not only the forbidden ones. A builtin is already
|
||||
* classified as fatal-or-lazy above; this list exists so the unresolved check
|
||||
* does not report one twice under a different heading. */
|
||||
const NODE_BUILTINS = [
|
||||
"assert", "async_hooks", "buffer", "child_process", "cluster", "console",
|
||||
"constants", "crypto", "dgram", "diagnostics_channel", "dns", "domain",
|
||||
"events", "fs", "http", "http2", "https", "inspector", "module", "net",
|
||||
"os", "path", "perf_hooks", "process", "punycode", "querystring",
|
||||
"readline", "repl", "stream", "string_decoder", "sys", "timers", "tls",
|
||||
"trace_events", "tty", "url", "util", "v8", "vm", "wasi", "worker_threads",
|
||||
"zlib",
|
||||
];
|
||||
|
||||
export const FORBIDDEN_BUILTINS = [
|
||||
"assert", "buffer", "child_process", "cluster", "crypto", "dgram", "dns",
|
||||
"events", "fs", "http", "http2", "https", "module", "net", "os", "path",
|
||||
@@ -37,7 +53,30 @@ export const FORBIDDEN_BUILTINS = [
|
||||
* Play, but a device kept offline keeps whatever it shipped with -- so the
|
||||
* floor there is the OS too, not the current Chrome.
|
||||
*/
|
||||
export const TARGETS = ["safari16", "chrome108"];
|
||||
/**
|
||||
* The oldest engines the bundle must PARSE on, derived from the platform
|
||||
* minimums the app declares -- not chosen independently of them.
|
||||
*
|
||||
* `chrome108` was set here while `tauri.conf.json` declared
|
||||
* `minSdkVersion: 26` (Android 8.0, WebView ~Chrome 58). The output contains
|
||||
* optional chaining, nullish coalescing and logical assignment -- all
|
||||
* post-Chrome-58 -- and `index.html` loads it as `<script type="module">`, so
|
||||
* on such a device the module body never evaluates. `installCrashHandler` is
|
||||
* imported by that same module, so it never installs either: a blank white
|
||||
* screen with no error surface and no telemetry.
|
||||
*
|
||||
* Android's WebView updates through Play, so most devices are far newer. The
|
||||
* ones that are not -- AOSP builds, Play-less devices, anything kept offline --
|
||||
* are exactly the population this floor exists to protect, which is what the
|
||||
* comment above already said before the two numbers drifted apart.
|
||||
*
|
||||
* Lowering the floor cost 5.2kB of a 400kB budget, measured.
|
||||
*
|
||||
* `bundle-target.test.mjs` asserts these stay derived from the declared
|
||||
* minimums, because nothing linked the two numbers and they silently diverged.
|
||||
*/
|
||||
export const ANDROID_WEBVIEW_FLOOR = { 26: "chrome58", 30: "chrome87", 33: "chrome108" };
|
||||
export const TARGETS = ["safari16", "chrome58"];
|
||||
|
||||
/** Bytes. Deliberately tight: this is a text UI, and the budget is what makes
|
||||
* a dependency a decision rather than an accident. */
|
||||
@@ -146,6 +185,23 @@ export async function bundle({ entry, outfile, minify = true, write = true }) {
|
||||
const forbidden = forbiddenAmong(bare);
|
||||
const fatal = forbidden.filter((p) => classified.get(p) === "static");
|
||||
const lazy = forbidden.filter((p) => classified.get(p) === "dynamic");
|
||||
// A bare import that is NOT a Node builtin and still left the build as an
|
||||
// external is one esbuild could not resolve -- the package is not installed.
|
||||
//
|
||||
// A webview has no module resolver. `import "openai"` in the shipped file is
|
||||
// a hard failure at import time, before any code runs, with the same blank
|
||||
// screen as a Node builtin -- and until now the gate said `shippable: true`
|
||||
// for it, because `problems` was built only from forbidden BUILTINS, top
|
||||
// level process.env, and size.
|
||||
//
|
||||
// Measured: bundling praisonai-ts's webview entry through this gate reported
|
||||
// 80.5kB, 0 problems and `shippable: true` while silently leaving out
|
||||
// openai, ai, @ai-sdk/cohere, @ai-sdk/google, @ai-sdk/openai,
|
||||
// @ai-sdk/provider-utils, chalk, ora, boxen, figlet and cli-table3. That
|
||||
// bundle loads on a laptop with node_modules beside it and dies on a phone.
|
||||
const unresolved = bare.filter(
|
||||
(name) => !RUNTIME_MARKERS.has(name) && !NODE_BUILTINS.includes(name.split("/")[0]),
|
||||
);
|
||||
const processReads = topLevelProcessReads(code);
|
||||
const bytes = Buffer.byteLength(code, "utf8");
|
||||
|
||||
@@ -164,13 +220,21 @@ export async function bundle({ entry, outfile, minify = true, write = true }) {
|
||||
`\n A webview has no process. Route these through a guarded accessor.`,
|
||||
);
|
||||
}
|
||||
if (unresolved.length > 0) {
|
||||
problems.push(
|
||||
`bare imports the build could not resolve: ${unresolved.join(", ")}.\n` +
|
||||
` A webview has no module resolver, so these fail at IMPORT time and the\n` +
|
||||
` screen stays blank -- exactly like a static Node builtin. Add them as\n` +
|
||||
` dependencies so they are bundled, or stop importing them.`,
|
||||
);
|
||||
}
|
||||
if (bytes > SIZE_BUDGET_BYTES) {
|
||||
problems.push(
|
||||
`bundle is ${(bytes / 1024).toFixed(1)}kB, over the ${(SIZE_BUDGET_BYTES / 1024).toFixed(0)}kB budget.`,
|
||||
);
|
||||
}
|
||||
|
||||
return { bytes, bare, forbidden, fatal, lazy, processReads, problems, metafile: result.metafile, code };
|
||||
return { bytes, bare, forbidden, fatal, lazy, unresolved, processReads, problems, metafile: result.metafile, code };
|
||||
}
|
||||
|
||||
/** CLI. Guarded so importing this module for tests does not run a build. */
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtemp, writeFile, mkdir } from "node:fs/promises";
|
||||
import { mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
@@ -259,3 +260,46 @@ test("a relative import that is external is not reported as a bare module", () =
|
||||
});
|
||||
assert.deepEqual([...bare.keys()], ["crypto"], "only the bare specifier is bare");
|
||||
});
|
||||
|
||||
test("the shipping bundle resolves everything it imports", async () => {
|
||||
// The positive control, and the one that would actually catch a regression:
|
||||
// the real app entry, through the real gate.
|
||||
const out = join(mkdtempSync(join(tmpdir(), "ship-")), "app.js");
|
||||
const report = await bundle({ entry: "app/src/main.ts", outfile: out, write: false });
|
||||
|
||||
assert.deepEqual(report.unresolved, [], `the shipped bundle must resolve everything: ${report.unresolved}`);
|
||||
assert.equal(isShippable(report), true, report.problems.join("\n"));
|
||||
});
|
||||
|
||||
test("an unresolvable import fails the gate, with the package named", async () => {
|
||||
// Driven through the REAL bundler against a real unresolvable specifier,
|
||||
// rather than a hand-built report -- the check has to survive esbuild's
|
||||
// actual externals behaviour, not my model of it.
|
||||
const dir = mkdtempSync(join(tmpdir(), "unres-"));
|
||||
const entry = join(dir, "probe.ts");
|
||||
writeFileSync(entry, 'import x from "a-package-that-is-not-installed";\nexport const y = x;\n');
|
||||
|
||||
const report = await bundle({ entry, outfile: join(dir, "o.js"), write: false });
|
||||
|
||||
assert.equal(isShippable(report), false, "an unresolvable import must not be shippable");
|
||||
assert.ok(
|
||||
report.unresolved.includes("a-package-that-is-not-installed"),
|
||||
`it must be listed: ${JSON.stringify(report.unresolved)}`,
|
||||
);
|
||||
assert.match(report.problems.join("\n"), /could not resolve/);
|
||||
assert.match(report.problems.join("\n"), /a-package-that-is-not-installed/, "and named in the message");
|
||||
});
|
||||
|
||||
test("a LAZY Node builtin is still allowed -- the pair", async () => {
|
||||
// The unresolved check must not swallow the static-vs-dynamic distinction
|
||||
// this file's header is built on: a dynamic `await import("readline")` in a
|
||||
// CLI-only path is unavailable on a phone, not fatal.
|
||||
const dir = mkdtempSync(join(tmpdir(), "lazy-"));
|
||||
const entry = join(dir, "probe.ts");
|
||||
writeFileSync(entry, 'export async function cli() { return await import("readline"); }\n');
|
||||
|
||||
const report = await bundle({ entry, outfile: join(dir, "o.js"), write: false });
|
||||
|
||||
assert.deepEqual(report.unresolved, [], "a builtin is classified as a builtin, not as unresolved");
|
||||
assert.deepEqual(report.fatal, [], "and a dynamic one is not fatal");
|
||||
});
|
||||
@@ -354,7 +354,18 @@ test("the size budget and the webview targets are the values the gate claims", (
|
||||
// whose threshold can be edited without a failing test is a gate that can be
|
||||
// turned off.
|
||||
assert.equal(SIZE_BUDGET_BYTES, 400 * 1024, "the budget is what makes a dependency a decision");
|
||||
assert.deepEqual(TARGETS, ["safari16", "chrome108"], "the WebView floor is the OS, not the current Chrome");
|
||||
// The Chrome floor is DERIVED from `minSdkVersion`, not restated here. This
|
||||
// line said `chrome108` while tauri.conf.json declared `minSdkVersion: 26`
|
||||
// -- Android 8.0, WebView ~Chrome 58 -- so the two numbers disagreed and a
|
||||
// literal in this test was what made the disagreement look deliberate.
|
||||
// `bundle-target.test.mjs` holds the relationship; this holds the shape.
|
||||
assert.equal(TARGETS.length, 2, "one floor per platform");
|
||||
assert.equal(TARGETS.find((t) => t.startsWith("safari")), "safari16", "iOS 16 is the declared minimum");
|
||||
assert.match(
|
||||
TARGETS.find((t) => t.startsWith("chrome")) ?? "",
|
||||
/^chrome\d+$/,
|
||||
"the WebView floor is the OS, not the current Chrome",
|
||||
);
|
||||
});
|
||||
|
||||
// ---- the allowlist glob is part of the gate, not a convenience -------------
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Write the release version, and a monotonic build number, into the Tauri
|
||||
* config the mobile bundler reads.
|
||||
*
|
||||
* The desktop app shipped v4.7.3 whose installer reported ProductVersion
|
||||
* 0.1.0, because `tauri.conf.json` was hardcoded and the bundler read it
|
||||
* verbatim (#4527). `praisonai-mobile` has the identical hardcoded `0.1.0`,
|
||||
* and on a store the consequence is worse than a wrong label:
|
||||
*
|
||||
* - Tauri derives Android `versionCode` from this file. Play REJECTS an
|
||||
* upload whose versionCode has been used before.
|
||||
* - It derives iOS `CFBundleVersion` from it. App Store Connect rejects a
|
||||
* duplicate CFBundleVersion within a CFBundleShortVersionString.
|
||||
*
|
||||
* So with the version frozen, the FIRST upload succeeds and the second is
|
||||
* refused outright. A release process that works exactly once is not one.
|
||||
*
|
||||
* `tauri.conf.json` is the single source the bundler reads -- deliberately not
|
||||
* synced with package.json or Cargo.toml, the same single-source rule the
|
||||
* desktop package settled on.
|
||||
*/
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
|
||||
const CONFIG = fileURLToPath(new URL("../src-tauri/tauri.conf.json", import.meta.url));
|
||||
const NUMERIC = "(?:0|[1-9]\\d*)";
|
||||
const PRERELEASE = `(?:${NUMERIC}|\\d*[A-Za-z-][0-9A-Za-z-]*)`;
|
||||
const SEMVER = new RegExp(
|
||||
`^${NUMERIC}\\.${NUMERIC}\\.${NUMERIC}(?:-${PRERELEASE}(?:\\.${PRERELEASE})*)?` +
|
||||
"(?:\\+[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?$");
|
||||
|
||||
/** Strip one conventional `v` prefix and validate a strict semantic version. */
|
||||
export function releaseVersion(tag) {
|
||||
const version = tag?.startsWith("v") ? tag.slice(1) : tag;
|
||||
if (!version || !SEMVER.test(version)) {
|
||||
throw new Error(`release tag "${tag ?? ""}" is not a semantic version`);
|
||||
}
|
||||
return version;
|
||||
}
|
||||
|
||||
/**
|
||||
* The store build number: strictly increasing, and independent of semver.
|
||||
*
|
||||
* Semver alone cannot serve. Both stores need a number that rises on EVERY
|
||||
* upload, including a re-upload of the same version after a rejection -- which
|
||||
* is precisely when a release is most likely to need one. CI passes
|
||||
* `github.run_number`, which only ever increases for a repository.
|
||||
*/
|
||||
export function buildNumber(run) {
|
||||
const n = Number(run);
|
||||
if (!Number.isInteger(n) || n < 1) {
|
||||
throw new Error(`build number "${run ?? ""}" is not a positive integer`);
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
/** Write both into the Tauri config consumed by the bundler. */
|
||||
export function setReleaseVersion(tag, run, configPath = CONFIG) {
|
||||
const config = JSON.parse(readFileSync(configPath, "utf8"));
|
||||
config.version = releaseVersion(tag);
|
||||
if (run !== undefined) {
|
||||
config.bundle ??= {};
|
||||
config.bundle.android ??= {};
|
||||
config.bundle.iOS ??= {};
|
||||
config.bundle.android.versionCode = buildNumber(run);
|
||||
config.bundle.iOS.buildNumber = String(buildNumber(run));
|
||||
}
|
||||
writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`);
|
||||
return { version: config.version, build: run === undefined ? null : buildNumber(run) };
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
try {
|
||||
const { version, build } = setReleaseVersion(process.argv[2], process.argv[3]);
|
||||
console.log(`mobile release version: ${version}${build === null ? "" : ` (build ${build})`}`);
|
||||
} catch (error) {
|
||||
console.error(error.message);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* The release version writer.
|
||||
*
|
||||
* Desktop shipped an installer reporting ProductVersion 0.1.0 because its
|
||||
* Tauri config was hardcoded (#4527). Mobile had the identical hardcoded
|
||||
* 0.1.0 and, on a store, the second upload is rejected outright rather than
|
||||
* merely mislabelled -- Play refuses a repeated versionCode and App Store
|
||||
* Connect a repeated CFBundleVersion.
|
||||
*/
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtempSync, writeFileSync, readFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { releaseVersion, buildNumber, setReleaseVersion } from "./set-release-version.mjs";
|
||||
|
||||
const fixture = (version = "0.1.0") => {
|
||||
const path = join(mkdtempSync(join(tmpdir(), "conf-")), "tauri.conf.json");
|
||||
writeFileSync(path, JSON.stringify({
|
||||
version,
|
||||
identifier: "ai.praison.mobile",
|
||||
bundle: { iOS: { minimumSystemVersion: "16.0" }, android: { minSdkVersion: 26 } },
|
||||
}, null, 2));
|
||||
return path;
|
||||
};
|
||||
|
||||
test("a release tag becomes the version the bundler reads", () => {
|
||||
const path = fixture();
|
||||
const { version } = setReleaseVersion("v4.8.0", "17", path);
|
||||
assert.equal(version, "4.8.0");
|
||||
assert.equal(JSON.parse(readFileSync(path, "utf8")).version, "4.8.0", "written, not just returned");
|
||||
});
|
||||
|
||||
test("one conventional v is stripped, and only one", () => {
|
||||
assert.equal(releaseVersion("v1.2.3"), "1.2.3");
|
||||
assert.equal(releaseVersion("1.2.3"), "1.2.3");
|
||||
assert.throws(() => releaseVersion("vv1.2.3"), /not a semantic version/);
|
||||
});
|
||||
|
||||
test("a tag that is not semver is refused, not guessed at", () => {
|
||||
// A bad tag must stop the release. Writing something plausible would ship a
|
||||
// version nobody chose, which is how #4527 shipped in the first place.
|
||||
for (const tag of ["", undefined, "latest", "1.2", "v1.2.3.4", "release-1"]) {
|
||||
assert.throws(() => releaseVersion(tag), /not a semantic version/, String(tag));
|
||||
}
|
||||
});
|
||||
|
||||
test("the build number rises on every upload, independent of semver", () => {
|
||||
// The case semver cannot serve: a re-upload of the SAME version after a
|
||||
// store rejection still needs a higher number, and that is exactly when a
|
||||
// release most needs one.
|
||||
const path = fixture();
|
||||
const first = setReleaseVersion("v4.8.0", "17", path);
|
||||
const second = setReleaseVersion("v4.8.0", "18", path);
|
||||
|
||||
assert.equal(first.version, second.version, "the same release");
|
||||
assert.ok(second.build > first.build, "with a higher build number");
|
||||
|
||||
const conf = JSON.parse(readFileSync(path, "utf8"));
|
||||
assert.equal(conf.bundle.android.versionCode, 18, "Play reads versionCode as an integer");
|
||||
assert.equal(conf.bundle.iOS.buildNumber, "18", "and CFBundleVersion is a string");
|
||||
});
|
||||
|
||||
test("a build number that cannot increase is refused", () => {
|
||||
for (const run of ["0", "-1", "1.5", "abc", ""]) {
|
||||
assert.throws(() => buildNumber(run), /positive integer/, String(run));
|
||||
}
|
||||
});
|
||||
|
||||
test("the platform minimums survive the rewrite -- the pair", () => {
|
||||
// The writer rewrites the whole file. Dropping a key it does not manage
|
||||
// would silently lower the deployment target.
|
||||
const path = fixture();
|
||||
setReleaseVersion("v4.8.0", "17", path);
|
||||
const conf = JSON.parse(readFileSync(path, "utf8"));
|
||||
assert.equal(conf.bundle.iOS.minimumSystemVersion, "16.0");
|
||||
assert.equal(conf.bundle.android.minSdkVersion, 26);
|
||||
assert.equal(conf.identifier, "ai.praison.mobile");
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* The Tauri config, checked against what the app actually does.
|
||||
*
|
||||
* `tauri.conf.json` is not covered by the TypeScript suite and is not compiled
|
||||
* by `cargo test`, so every value in it was unasserted. Two of them decide
|
||||
* whether the app can function on a device at all.
|
||||
*/
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
const conf = JSON.parse(
|
||||
readFileSync(join(import.meta.dirname, "../src-tauri/tauri.conf.json"), "utf8"),
|
||||
);
|
||||
const directive = (name) =>
|
||||
(conf.app.security.csp.split(";").find((d) => d.trim().startsWith(name)) ?? "").trim();
|
||||
|
||||
test("the CSP permits the engine addresses the settings allow", () => {
|
||||
// `connect-src 'self' ipc: http://ipc.localhost` permitted NO engine at all.
|
||||
// `baseUrl` is user-settable to any host (app/src/registry.ts:58), the HTTP
|
||||
// adapter under Tauri is still browser `fetch` (app/src/platform.ts), and a
|
||||
// webview enforces connect-src on fetch -- so every remote engine was
|
||||
// blocked before it reached the network, including the shipped default of
|
||||
// http://127.0.0.1:8765.
|
||||
//
|
||||
// The desktop app already had the loopback entries; mobile did not, and
|
||||
// mobile additionally needs https:, because on a phone the engine is across
|
||||
// a network rather than on the same machine.
|
||||
const connect = directive("connect-src");
|
||||
assert.match(connect, /https:/, "an https engine address must be reachable");
|
||||
assert.match(connect, /http:\/\/127\.0\.0\.1:\*/, "and the shipped loopback default");
|
||||
assert.match(connect, /\bipc:/, "and Tauri's own IPC");
|
||||
});
|
||||
|
||||
test("the CSP still refuses remote SCRIPTS -- the pair", () => {
|
||||
// Widening connect-src must not widen the thing that matters. A model can
|
||||
// return arbitrary text and a tool result is attacker-shaped in the ordinary
|
||||
// case; script-src is what stops that becoming code in the app's origin.
|
||||
const script = directive("script-src");
|
||||
assert.equal(script, "script-src 'self'", `script-src must stay closed: ${script}`);
|
||||
assert.doesNotMatch(script, /unsafe-inline|unsafe-eval|https:/);
|
||||
assert.match(directive("default-src"), /^default-src 'self'$/);
|
||||
});
|
||||
|
||||
test("the mobile platform minimums are declared", () => {
|
||||
// Tauri needs these to generate the platform projects; a missing value is a
|
||||
// default that may be below what the code assumes.
|
||||
assert.equal(conf.bundle.iOS.minimumSystemVersion, "16.0");
|
||||
assert.equal(conf.bundle.android.minSdkVersion, 26);
|
||||
});
|
||||
|
||||
test("the identifier is a real reverse-DNS id, not the Tauri placeholder", () => {
|
||||
// Both stores key the app on this forever, and Tauri's own template value
|
||||
// ships as `com.tauri.dev`, which cannot be uploaded.
|
||||
assert.match(conf.identifier, /^[a-z][a-z0-9]*(\.[a-z][a-z0-9-]*)+$/, conf.identifier);
|
||||
assert.doesNotMatch(conf.identifier, /tauri\.dev|example|todo/i);
|
||||
});
|
||||
Reference in new issue
Block a user