diff --git a/README.md b/README.md
index 145eb7c..4d7bec2 100644
--- a/README.md
+++ b/README.md
@@ -215,6 +215,7 @@ If you want the technical breakdown instead of the public pitch, read [docs/ARCH
| Runtime verification | [docs/RUNTIME-VERIFICATION.md](docs/RUNTIME-VERIFICATION.md) |
| Security audit | [docs/SECURITY-AUDIT.md](docs/SECURITY-AUDIT.md) |
| WebView harness | [docs/WEBVIEW-HARNESS.md](docs/WEBVIEW-HARNESS.md) |
+| Proxy deployment | [docs/PROXY-DEPLOYMENT.md](docs/PROXY-DEPLOYMENT.md) |
| Release runbook | [docs/RELEASE-RUNBOOK.md](docs/RELEASE-RUNBOOK.md) |
| Publishing checklist | [docs/PUBLISHING-CHECKLIST.md](docs/PUBLISHING-CHECKLIST.md) |
| Showcase | [docs/SHOWCASE.md](docs/SHOWCASE.md) |
@@ -227,6 +228,7 @@ If you want the technical breakdown instead of the public pitch, read [docs/ARCH
- HTTPS is preferred when you can issue a trusted certificate.
- Cleartext HTTP remains supported for Tailscale or another trusted private network because that is a core self-hosted use case, but the app now warns before every HTTP session.
- The app intentionally stays scoped to one configured server host inside the WebView; other destinations open outside the app.
+- The optional proxy is self-hosted infrastructure for trusted networks. Check [docs/PROXY-DEPLOYMENT.md](docs/PROXY-DEPLOYMENT.md) before exposing it through any reverse proxy.
See [docs/SECURITY-AUDIT.md](docs/SECURITY-AUDIT.md) for the current audit notes, fixes, and remaining tradeoffs.
diff --git a/SECURITY.md b/SECURITY.md
index 64d0ad6..796f9a9 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -33,3 +33,5 @@ Target response windows:
- Use trusted certificates for HTTPS where possible.
- Do not commit TLS keys, certificates, `.env` files, or signed build artifacts to the repository.
- Review WebView and proxy settings carefully before broader deployment.
+
+Proxy deployment boundaries are documented in [docs/PROXY-DEPLOYMENT.md](docs/PROXY-DEPLOYMENT.md).
diff --git a/docs/PROXY-DEPLOYMENT.md b/docs/PROXY-DEPLOYMENT.md
new file mode 100644
index 0000000..89128fa
--- /dev/null
+++ b/docs/PROXY-DEPLOYMENT.md
@@ -0,0 +1,44 @@
+# Proxy Deployment Checklist
+
+Use the optional proxy as self-hosted infrastructure for a T3 Code session you already control. It is not a hardened public edge service.
+
+## Appropriate Use
+
+- Use it on Tailscale, a trusted LAN, or behind a reverse proxy you already operate.
+- Prefer HTTPS with a certificate trusted by the client device.
+- Use `PROXY_HTTP=true` only behind Tailscale Serve or another HTTPS-terminating private reverse proxy.
+- Keep `T3_TARGET` pointed at a T3 Code instance on a private host or loopback address.
+- Keep `GET /__t3mobile/health` available for smoke tests and support checks.
+
+## Do Not Use It This Way
+
+- Do not expose the proxy directly to the public internet as an unauthenticated gateway.
+- Do not use self-signed certificates on networks you do not control.
+- Do not point `T3_TARGET` at an untrusted upstream host.
+- Do not publish `.env` files, TLS keys, certificates, signed APKs, or local diagnostic output.
+- Do not describe the proxy as production-hardened public infrastructure.
+
+## Minimum Setup
+
+1. Set `T3_TARGET` to your local T3 Code URL, for example `http://127.0.0.1:3773`.
+2. Set `PUBLIC_URL` to the URL your phone will open.
+3. For built-in HTTPS mode, set `SSL_KEY_PATH` and `SSL_CERT_PATH` to trusted certificate files.
+4. For HTTP mode behind another HTTPS layer, set `PROXY_HTTP=true` and keep the HTTP listener private.
+5. Run `npm test` before sharing setup instructions or publishing a release.
+
+## Verification
+
+Run the automated proxy smoke test:
+
+```bash
+npm run test:proxy
+```
+
+Then verify the deployed proxy from the phone network path:
+
+- `GET /__t3mobile/health` returns JSON and does not expose filesystem paths.
+- The reported upstream status is healthy.
+- The root page loads through the expected HTTPS or trusted private-network path.
+- Browser dev tools or proxy logs do not show TLS key paths, tokens, or private filesystem paths.
+
+If any of those checks fail, treat the deployment as not verified.
diff --git a/docs/SECURITY-AUDIT.md b/docs/SECURITY-AUDIT.md
index 1921db0..7ac4a14 100644
--- a/docs/SECURITY-AUDIT.md
+++ b/docs/SECURITY-AUDIT.md
@@ -270,6 +270,29 @@ Current behavior:
- health results are cached for 5 seconds to prevent probe amplification
+## Maintenance Updates (May 5, 2026)
+
+### 15. Proxy deployment boundaries documented
+
+Previous behavior:
+
+- proxy deployment warnings were spread across README, SECURITY, and audit notes
+
+Current behavior:
+
+- `docs/PROXY-DEPLOYMENT.md` now provides a concrete checklist for trusted-network use, unsafe public exposure patterns, minimum setup, and verification
+
+### 16. Upload-injection path has automated assertions
+
+Previous behavior:
+
+- upload-button behavior was stronger visually than mechanically asserted
+
+Current behavior:
+
+- `npm test` now runs DOM-level assertions for proxy upload button placement, hidden file input creation, paste-event dispatch, and fallback placement
+- Android WebView upload injection has source-marker checks for the critical IDs, composer selectors, paste flow, observer, and fallback path
+
## Recommended Next Security Steps
- add optional host allowlisting for more than one trusted origin if the product needs it
diff --git a/docs/STARTER-ISSUES.md b/docs/STARTER-ISSUES.md
index 3cbed18..76e8ffe 100644
--- a/docs/STARTER-ISSUES.md
+++ b/docs/STARTER-ISSUES.md
@@ -20,40 +20,41 @@ Labels:
- `docs`
- `help wanted`
-## 2. Add automated assertion coverage for the upload-button path
+## 2. Harden DOM targeting for composer upload injection
Why it matters:
-- the upload flow is one of the core product claims
-- it is still stronger visually than it is mechanically asserted
+- upstream UI drift is a realistic long-term risk
+- this is a good contributor-sized reliability issue
Suggested scope:
-- expand the local harness or verification flow so the injected control can be asserted more directly
-- document what is and is not guaranteed by the automated check
+- review the current selector strategy in `MainActivity.java`
+- reduce brittle assumptions where possible
+- keep fallback placement behavior documented
Labels:
- `android`
-- `help wanted`
+- `good first issue`
-## 3. Harden DOM targeting for composer upload injection
+## 3. Add iPhone PWA runtime evidence
Why it matters:
-- upstream UI drift is a realistic long-term risk
-- this is a good contributor-sized reliability issue
+- the PWA path has automated proxy coverage but still needs real-device proof
+- iOS-specific install and safe-area behavior should be checked on hardware
Suggested scope:
-- review the current selector strategy in `MainActivity.java`
-- reduce brittle assumptions where possible
-- keep fallback placement behavior documented
+- install the PWA on a real iPhone or iPad
+- capture connection, launch-from-home-screen, and proxy health evidence
+- update `IPHONE-GUIDE.md` if device behavior differs from the current notes
Labels:
-- `android`
-- `good first issue`
+- `docs`
+- `help wanted`
## 4. Add a release demo clip for the README and release page
@@ -72,20 +73,20 @@ Labels:
- `community`
- `docs`
-## 5. Improve proxy deployment guidance for self-hosted users
+## 5. Add release signing verification notes
Why it matters:
-- the proxy is useful but easy to misunderstand
-- better deployment guidance lowers support overhead
+- Android update trust depends on stable signing identity
+- signing continuity is the highest-risk release operations gap
Suggested scope:
-- expand docs around trusted-network assumptions
-- add a short “when not to expose this” checklist
-- keep the guidance aligned with `SECURITY.md`
+- document the intended public release signing fingerprint
+- add a release-time manual verification step for signer continuity
+- keep private signing material out of the repo
Labels:
-- `proxy`
-- `docs`
+- `android`
+- `security`
diff --git a/docs/UPSTREAM-FIT.md b/docs/UPSTREAM-FIT.md
index 846a889..843f349 100644
--- a/docs/UPSTREAM-FIT.md
+++ b/docs/UPSTREAM-FIT.md
@@ -19,7 +19,7 @@ This project is most credible to `pingdotgg/t3code` when it stays narrow and rel
## Current Validation
Last checked: 2026-05-05 on `main` after the ESLint 10 and Node runtime update in
-[`JSvandijk/t3code-mobile#19`](https://github.com/JSvandijk/t3code-mobile/pull/19).
+[`JSvandijk/t3code-mobile#19`](https://github.com/JSvandijk/t3code-mobile/pull/19), plus the follow-up proxy deployment and upload-assertion maintenance.
- `npm test` passes from a clean local checkout after `npm ci`.
- JavaScript syntax checks pass.
@@ -27,6 +27,7 @@ Last checked: 2026-05-05 on `main` after the ESLint 10 and Node runtime update i
- `manifest.json` validation passes.
- Release checks pass for version `1.1.0`.
- HTML unit tests pass: 18/18.
+- Upload-injection tests pass: 5/5.
- Proxy smoke test passes, including HTML injection, static assets, and `GET /__t3mobile/health`.
- `cmd /c build-apk.bat` builds a dev-signed APK successfully.
diff --git a/docs/WEBVIEW-HARNESS.md b/docs/WEBVIEW-HARNESS.md
index c20af70..058d3c9 100644
--- a/docs/WEBVIEW-HARNESS.md
+++ b/docs/WEBVIEW-HARNESS.md
@@ -66,6 +66,7 @@ Use `npm run harness:redirect` to confirm:
- If those files are absent, the harness generates a temporary self-signed certificate automatically.
- Override paths with `SSL_KEY_PATH` and `SSL_CERT_PATH` if needed.
- The harness also exposes `/status` for quick local checks.
+- `npm test` runs DOM-level upload injection assertions for the proxy path and source-marker checks for the Android WebView path.
- Pair this guide with [RUNTIME-VERIFICATION.md](RUNTIME-VERIFICATION.md) when you are collecting release evidence.
## Recommended Evidence To Capture
diff --git a/docs/evidence/v1.1.0-emulator/README.md b/docs/evidence/v1.1.0-emulator/README.md
index 206f70e..640e5d3 100644
--- a/docs/evidence/v1.1.0-emulator/README.md
+++ b/docs/evidence/v1.1.0-emulator/README.md
@@ -18,7 +18,7 @@ This evidence set does not prove:
- behavior on a physical Android device
- microphone permission flow
- real T3 Code upstream behavior beyond the local harness and existing repo smoke tests
-- upload-button behavior by machine-readable assertion
+- Android upload-button behavior by current runtime evidence
## Environment
@@ -150,5 +150,5 @@ This evidence set was captured against commit `7b7680807bd97f06802fd98783096dd37
## Status Summary
- Proven by emulator runtime evidence: connect screen, diagnostics path, pairing-link input acceptance, cleartext warning, base URL normalization, invalid HTTPS blocking
-- Proven by automated checks: manifest integrity, release gates, proxy smoke path, proxy header expectations
-- Still not proven here: physical-device behavior, microphone permission path, and machine-verifiable upload-button assertion
+- Proven by current automated checks: manifest integrity, release gates, proxy smoke path, proxy header expectations, proxy upload-injection DOM assertions
+- Still not proven here: physical-device behavior, microphone permission path, and Android upload-button behavior on the latest runtime
diff --git a/lib/upload-injection.js b/lib/upload-injection.js
new file mode 100644
index 0000000..0914e1e
--- /dev/null
+++ b/lib/upload-injection.js
@@ -0,0 +1,147 @@
+const uploadInjectionScript = `
+ (function() {
+ if (window.__t3MobileObserverAttached) return;
+ window.__t3MobileObserverAttached = true;
+
+ function getComposerTarget() {
+ var selectors = [
+ '[data-chat-composer-form] textarea',
+ '[data-chat-composer-form] [contenteditable="true"]',
+ '[role="textbox"]',
+ 'textarea',
+ '[contenteditable="true"]'
+ ];
+ for (var i = 0; i < selectors.length; i++) {
+ var node = document.querySelector(selectors[i]);
+ if (node) return node;
+ }
+ return null;
+ }
+
+ function getFileInput() {
+ var fi = document.getElementById('t3-file-input');
+ if (fi && fi.parentNode) return fi;
+ if (fi) fi.remove();
+ fi = document.createElement('input');
+ fi.type = 'file';
+ fi.accept = 'image/*';
+ fi.style.display = 'none';
+ fi.id = 't3-file-input';
+ fi.addEventListener('change', handleFile);
+ document.body.appendChild(fi);
+ return fi;
+ }
+
+ function handleFile(e) {
+ var file = e.target.files[0];
+ if (!file) return;
+ var dt = new DataTransfer();
+ dt.items.add(file);
+ var target = getComposerTarget();
+ if (target) {
+ target.focus();
+ target.dispatchEvent(new ClipboardEvent('paste', {
+ bubbles: true, cancelable: true, clipboardData: dt
+ }));
+ }
+ e.target.value = '';
+ }
+
+ function findAnchorButton() {
+ var footer = document.querySelector('[data-chat-composer-footer]');
+ if (footer) {
+ var buttons = footer.querySelectorAll('button');
+ for (var i = buttons.length - 1; i >= 0; i--) {
+ if (buttons[i].querySelectorAll('circle').length === 3) return buttons[i];
+ }
+ if (buttons.length) return buttons[buttons.length - 1];
+ }
+ var composer = document.querySelector('[data-chat-composer-form]');
+ if (composer) {
+ var composerButtons = composer.querySelectorAll('button');
+ if (composerButtons.length) return composerButtons[composerButtons.length - 1];
+ }
+ return null;
+ }
+
+ function getFallbackContainer() {
+ var composer = document.querySelector('[data-chat-composer-form]');
+ if (!composer) return null;
+ var container = document.getElementById('t3-mobile-actions');
+ if (container && container.parentNode === composer) return container;
+ if (container) container.remove();
+ container = document.createElement('div');
+ container.id = 't3-mobile-actions';
+ container.style.display = 'flex';
+ container.style.justifyContent = 'flex-end';
+ container.style.marginTop = '8px';
+ composer.appendChild(container);
+ return container;
+ }
+
+ function ensureButton() {
+ var anchor = findAnchorButton();
+ var existing = document.getElementById('t3-img-btn');
+ var fallback = getFallbackContainer();
+ if (anchor && anchor.parentNode) {
+ if (existing && existing.parentNode === anchor.parentNode) return true;
+ if (existing) existing.remove();
+ } else if (fallback) {
+ if (existing && existing.parentNode === fallback) return true;
+ if (existing) existing.remove();
+ } else {
+ return false;
+ }
+ var btn = document.createElement('button');
+ btn.id = 't3-img-btn';
+ btn.type = 'button';
+ btn.title = 'Add image';
+ btn.setAttribute('aria-label', 'Add image');
+ btn.className = anchor ? anchor.className : '';
+ if (!anchor) {
+ btn.style.minWidth = '40px';
+ btn.style.minHeight = '40px';
+ btn.style.borderRadius = '12px';
+ btn.style.border = '1px solid rgba(148, 163, 184, 0.25)';
+ btn.style.background = 'transparent';
+ btn.style.color = 'inherit';
+ btn.style.display = 'inline-flex';
+ btn.style.alignItems = 'center';
+ btn.style.justifyContent = 'center';
+ }
+ btn.innerHTML = '';
+ btn.addEventListener('click', function(ev) {
+ ev.preventDefault();
+ ev.stopPropagation();
+ getFileInput().click();
+ });
+ if (anchor && anchor.parentNode) {
+ anchor.parentNode.insertBefore(btn, anchor.nextSibling);
+ } else {
+ fallback.appendChild(btn);
+ }
+ return true;
+ }
+
+ function init() {
+ ensureButton();
+ var observerTicking = false;
+ new MutationObserver(function() {
+ if (observerTicking) return;
+ observerTicking = true;
+ requestAnimationFrame(function() {
+ observerTicking = false;
+ ensureButton();
+ });
+ }).observe(document.body, { childList: true, subtree: true });
+ }
+
+ if (document.body) {
+ init();
+ } else {
+ document.addEventListener('DOMContentLoaded', init);
+ }
+ })();
+`;
+
+module.exports = { uploadInjectionScript };
diff --git a/package-lock.json b/package-lock.json
index e05f61d..62eb360 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -12,7 +12,7 @@
"http-proxy": "^1.18.1"
},
"devDependencies": {
- "eslint": "^10.2.1",
+ "eslint": "^10.3.0",
"selfsigned": "^5.5.0"
},
"engines": {
@@ -514,9 +514,9 @@
}
},
"node_modules/eslint": {
- "version": "10.2.1",
- "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.2.1.tgz",
- "integrity": "sha512-wiyGaKsDgqXvF40P8mDwiUp/KQjE1FdrIEJsM8PZ3XCiniTMXS3OHWWUe5FI5agoCnr8x4xPrTDZuxsBlNHl+Q==",
+ "version": "10.3.0",
+ "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.3.0.tgz",
+ "integrity": "sha512-XbEXaRva5cF0ZQB8w6MluHA0kZZfV2DuCMJ3ozyEOHLwDpZX2Lmm/7Pp0xdJmI0GL1W05VH5VwIFHEm1Vcw2gw==",
"dev": true,
"license": "MIT",
"dependencies": {
diff --git a/package.json b/package.json
index f1126fa..d127b9b 100644
--- a/package.json
+++ b/package.json
@@ -7,8 +7,8 @@
"start": "node server.js",
"build:apk": "build-apk.bat",
"generate:icons": "node generate-icons.js",
- "check:js": "node --check server.js && node --check generate-icons.js && node --check sw.js && node --check check-release.js && node --check smoke-proxy.js && node --check webview-harness.js && node --check lib/html.js && node --check test/test-html.js",
- "lint": "eslint server.js smoke-proxy.js check-release.js webview-harness.js generate-icons.js sw.js lib/html.js test/test-html.js",
+ "check:js": "node --check server.js && node --check generate-icons.js && node --check sw.js && node --check check-release.js && node --check smoke-proxy.js && node --check webview-harness.js && node --check lib/html.js && node --check lib/upload-injection.js && node --check test/test-html.js && node --check test/test-upload-injection.js",
+ "lint": "eslint server.js smoke-proxy.js check-release.js webview-harness.js generate-icons.js sw.js lib/html.js lib/upload-injection.js test/test-html.js test/test-upload-injection.js",
"check:manifest": "node -e \"JSON.parse(require('fs').readFileSync('manifest.json', 'utf8')); console.log('manifest.json OK')\"",
"check:repo": "node check-release.js",
"check:release": "npm run check:repo",
@@ -17,7 +17,7 @@
"harness:http": "node webview-harness.js --mode=http",
"harness:https-bad-cert": "node webview-harness.js --mode=https-bad-cert",
"harness:redirect": "node webview-harness.js --mode=redirect",
- "test:units": "node test/test-html.js",
+ "test:units": "node test/test-html.js && node test/test-upload-injection.js",
"test": "npm run check:js && npm run lint && npm run check:manifest && npm run check:repo && npm run test:units && npm run test:proxy"
},
"keywords": [
@@ -49,7 +49,7 @@
"http-proxy": "^1.18.1"
},
"devDependencies": {
- "eslint": "^10.2.1",
+ "eslint": "^10.3.0",
"selfsigned": "^5.5.0"
}
}
diff --git a/server.js b/server.js
index 985fb2b..3a53927 100644
--- a/server.js
+++ b/server.js
@@ -6,6 +6,7 @@ const fs = require('fs');
const path = require('path');
const pkg = require('./package.json');
const { escapeHtml, injectBeforeHeadClose } = require('./lib/html');
+const { uploadInjectionScript } = require('./lib/upload-injection');
const repoPath = (targetPath) => path.resolve(__dirname, targetPath);
const fromEnvOrDefault = (name, fallback) => process.env[name] || fallback;
@@ -174,149 +175,7 @@ const pwaInject = `
}
`;
diff --git a/test/test-upload-injection.js b/test/test-upload-injection.js
new file mode 100644
index 0000000..5b522f2
--- /dev/null
+++ b/test/test-upload-injection.js
@@ -0,0 +1,321 @@
+const assert = require('assert');
+const vm = require('vm');
+const fs = require('fs');
+const path = require('path');
+const { uploadInjectionScript } = require('../lib/upload-injection');
+
+let passed = 0;
+
+function test(name, fn) {
+ try {
+ fn();
+ passed++;
+ } catch (e) {
+ console.error(`FAIL: ${name}`);
+ console.error(` ${e.stack || e.message}`);
+ process.exit(1);
+ }
+}
+
+class FakeElement {
+ constructor(tagName) {
+ this.tagName = tagName.toUpperCase();
+ this.children = [];
+ this.parentNode = null;
+ this.attributes = {};
+ this.listeners = {};
+ this.style = {};
+ this.id = '';
+ this.type = '';
+ this.title = '';
+ this.className = '';
+ this.accept = '';
+ this.files = [];
+ this.value = '';
+ this.innerHTML = '';
+ this.focused = false;
+ this.clicked = false;
+ }
+
+ appendChild(child) {
+ if (child.parentNode) child.remove();
+ child.parentNode = this;
+ this.children.push(child);
+ return child;
+ }
+
+ insertBefore(child, referenceNode) {
+ if (child.parentNode) child.remove();
+ child.parentNode = this;
+ const index = this.children.indexOf(referenceNode);
+ if (index === -1) {
+ this.children.push(child);
+ } else {
+ this.children.splice(index, 0, child);
+ }
+ return child;
+ }
+
+ remove() {
+ if (!this.parentNode) return;
+ const index = this.parentNode.children.indexOf(this);
+ if (index !== -1) this.parentNode.children.splice(index, 1);
+ this.parentNode = null;
+ }
+
+ setAttribute(name, value) {
+ this.attributes[name] = String(value);
+ if (name === 'id') this.id = String(value);
+ if (name === 'class') this.className = String(value);
+ }
+
+ getAttribute(name) {
+ if (name === 'id') return this.id || null;
+ if (name === 'class') return this.className || null;
+ return Object.prototype.hasOwnProperty.call(this.attributes, name)
+ ? this.attributes[name]
+ : null;
+ }
+
+ addEventListener(type, listener) {
+ if (!this.listeners[type]) this.listeners[type] = [];
+ this.listeners[type].push(listener);
+ }
+
+ dispatchEvent(event) {
+ event.target = event.target || this;
+ const listeners = this.listeners[event.type] || [];
+ for (const listener of listeners) {
+ listener(event);
+ }
+ return true;
+ }
+
+ click() {
+ this.clicked = true;
+ this.dispatchEvent({
+ type: 'click',
+ preventDefault() {},
+ stopPropagation() {},
+ });
+ }
+
+ focus() {
+ this.focused = true;
+ }
+
+ querySelector(selector) {
+ return querySelectorFrom(this, selector);
+ }
+
+ querySelectorAll(selector) {
+ return querySelectorAllFrom(this, selector);
+ }
+}
+
+class FakeDocument {
+ constructor() {
+ this.body = new FakeElement('body');
+ this.eventListeners = {};
+ }
+
+ createElement(tagName) {
+ return new FakeElement(tagName);
+ }
+
+ getElementById(id) {
+ return walk(this.body).find((node) => node.id === id) || null;
+ }
+
+ querySelector(selector) {
+ return querySelectorFrom(this.body, selector);
+ }
+
+ querySelectorAll(selector) {
+ return querySelectorAllFrom(this.body, selector);
+ }
+
+ addEventListener(type, listener) {
+ this.eventListeners[type] = listener;
+ }
+}
+
+function walk(root) {
+ const nodes = [];
+ const visit = (node) => {
+ nodes.push(node);
+ for (const child of node.children) visit(child);
+ };
+ visit(root);
+ return nodes;
+}
+
+function querySelectorFrom(root, selector) {
+ return querySelectorAllFrom(root, selector)[0] || null;
+}
+
+function querySelectorAllFrom(root, selector) {
+ const parts = selector.trim().split(/\s+/);
+ return walk(root).filter((node) => matchesSelectorChain(node, parts));
+}
+
+function matchesSelectorChain(node, parts) {
+ if (!matchesSimpleSelector(node, parts[parts.length - 1])) return false;
+ let ancestor = node.parentNode;
+ for (let i = parts.length - 2; i >= 0; i--) {
+ while (ancestor && !matchesSimpleSelector(ancestor, parts[i])) {
+ ancestor = ancestor.parentNode;
+ }
+ if (!ancestor) return false;
+ ancestor = ancestor.parentNode;
+ }
+ return true;
+}
+
+function matchesSimpleSelector(node, selector) {
+ const attrMatch = selector.match(/^\[([^=\]]+)(?:="([^"]+)")?\]$/);
+ if (attrMatch) {
+ const actual = node.getAttribute(attrMatch[1]);
+ return attrMatch[2] === undefined ? actual !== null : actual === attrMatch[2];
+ }
+ return node.tagName.toLowerCase() === selector.toLowerCase();
+}
+
+function createContext(document) {
+ const clipboardEvents = [];
+ return {
+ context: {
+ document,
+ window: {},
+ console,
+ requestAnimationFrame: (fn) => fn(),
+ MutationObserver: class {
+ constructor(callback) {
+ this.callback = callback;
+ }
+ observe() {}
+ },
+ DataTransfer: class {
+ constructor() {
+ this.items = {
+ values: [],
+ add: (file) => this.items.values.push(file),
+ };
+ }
+ },
+ ClipboardEvent: class {
+ constructor(type, options) {
+ this.type = type;
+ this.clipboardData = options.clipboardData;
+ this.bubbles = options.bubbles;
+ this.cancelable = options.cancelable;
+ clipboardEvents.push(this);
+ }
+ },
+ },
+ clipboardEvents,
+ };
+}
+
+function runUploadScript(document) {
+ const { context, clipboardEvents } = createContext(document);
+ vm.runInNewContext(uploadInjectionScript, context);
+ return { context, clipboardEvents };
+}
+
+function buildComposer({ withFooterAnchor = true } = {}) {
+ const document = new FakeDocument();
+ const form = document.createElement('form');
+ form.setAttribute('data-chat-composer-form', '');
+ const textarea = document.createElement('textarea');
+ form.appendChild(textarea);
+
+ if (withFooterAnchor) {
+ const footer = document.createElement('div');
+ footer.setAttribute('data-chat-composer-footer', '');
+ const anchor = document.createElement('button');
+ anchor.className = 'composer-button';
+ anchor.appendChild(document.createElement('circle'));
+ anchor.appendChild(document.createElement('circle'));
+ anchor.appendChild(document.createElement('circle'));
+ footer.appendChild(anchor);
+ form.appendChild(footer);
+ }
+
+ document.body.appendChild(form);
+ return { document, form, textarea };
+}
+
+test('proxy upload injection places image button next to composer footer action', () => {
+ const { document } = buildComposer({ withFooterAnchor: true });
+ runUploadScript(document);
+
+ const button = document.getElementById('t3-img-btn');
+ assert.ok(button, 'expected injected image button');
+ assert.strictEqual(button.getAttribute('aria-label'), 'Add image');
+ assert.strictEqual(button.parentNode.getAttribute('data-chat-composer-footer'), '');
+ assert.strictEqual(button.parentNode.children.indexOf(button), 1);
+});
+
+test('proxy upload button creates hidden image file input on click', () => {
+ const { document } = buildComposer({ withFooterAnchor: true });
+ runUploadScript(document);
+
+ document.getElementById('t3-img-btn').click();
+ const input = document.getElementById('t3-file-input');
+ assert.ok(input, 'expected hidden file input');
+ assert.strictEqual(input.type, 'file');
+ assert.strictEqual(input.accept, 'image/*');
+ assert.strictEqual(input.style.display, 'none');
+ assert.strictEqual(input.clicked, true);
+});
+
+test('proxy upload change dispatches paste event into composer target', () => {
+ const { document, textarea } = buildComposer({ withFooterAnchor: true });
+ const { clipboardEvents } = runUploadScript(document);
+
+ document.getElementById('t3-img-btn').click();
+ const input = document.getElementById('t3-file-input');
+ input.files = [{ name: 'photo.png', type: 'image/png' }];
+ input.dispatchEvent({ type: 'change', target: input });
+
+ assert.strictEqual(textarea.focused, true);
+ assert.strictEqual(input.value, '');
+ assert.strictEqual(clipboardEvents.length, 1);
+ assert.strictEqual(clipboardEvents[0].type, 'paste');
+ assert.strictEqual(clipboardEvents[0].clipboardData.items.values[0].name, 'photo.png');
+});
+
+test('proxy upload injection falls back when composer footer is unavailable', () => {
+ const { document, form } = buildComposer({ withFooterAnchor: false });
+ runUploadScript(document);
+
+ const fallback = document.getElementById('t3-mobile-actions');
+ const button = document.getElementById('t3-img-btn');
+ assert.ok(fallback, 'expected fallback action container');
+ assert.ok(button, 'expected injected fallback button');
+ assert.strictEqual(fallback.parentNode, form);
+ assert.strictEqual(button.parentNode, fallback);
+});
+
+test('Android WebView upload injection still contains core reliability markers', () => {
+ const source = fs.readFileSync(
+ path.join(__dirname, '..', 'apk', 'app', 'src', 'main', 'java', 'com', 't3code', 'app', 'MainActivity.java'),
+ 'utf8',
+ );
+
+ for (const marker of [
+ 'data-chat-composer-form',
+ 'data-chat-composer-footer',
+ 't3-file-input',
+ 't3-img-btn',
+ 't3-mobile-actions',
+ 'ClipboardEvent',
+ 'MutationObserver',
+ 'Using fallback upload button placement',
+ ]) {
+ assert.ok(source.includes(marker), `missing Android upload marker: ${marker}`);
+ }
+});
+
+console.log(`upload injection tests OK (${passed} passed)`);