diff --git a/frontend/dashboard.js b/frontend/dashboard.js
index 167b4af..6d874dd 100644
--- a/frontend/dashboard.js
+++ b/frontend/dashboard.js
@@ -533,6 +533,12 @@
}).catch(() => { /* The foreground localStorage outboxes remain available. */ });
}
const shareParams = new URLSearchParams(location.search);
+ const appShortcut = mobileAppShortcuts.createController({
+ search: location.search,
+ continueWork: () => mobileWorkEntry.open(),
+ newIssue: () => openCreateIssueSheet(),
+ agenda: () => openAgendaSession(),
+ });
const sharedLaunch = {
title: shareParams.get('title') || '',
text: shareParams.get('text') || '',
@@ -6234,6 +6240,8 @@
if (!deviceSetup) await (await ensureDeviceSetup()).open(event);
});
pushControllerReady.then(ensureDeviceSetup).catch(console.warn);
+ await load();
+ await appShortcut.run();
contextPoller.start();
document.addEventListener('visibilitychange', () => {
contextPoller.setVisible(!document.hidden);
diff --git a/frontend/index.html b/frontend/index.html
index 1d9f5cf..b526b8e 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -1057,6 +1057,7 @@
+
diff --git a/frontend/manifest.webmanifest b/frontend/manifest.webmanifest
index 8e0f8db..7b8dd77 100644
--- a/frontend/manifest.webmanifest
+++ b/frontend/manifest.webmanifest
@@ -12,6 +12,29 @@
{"src": "static/icons/stackchain-192.png", "sizes": "192x192", "type": "image/png"},
{"src": "static/icons/stackchain-512.png", "sizes": "512x512", "type": "image/png"}
],
+ "shortcuts": [
+ {
+ "name": "Continue work",
+ "short_name": "Continue",
+ "description": "Resume the highest-priority mobile work flow.",
+ "url": "./?launch=continue",
+ "icons": [{"src": "static/icons/stackchain-192.png", "sizes": "192x192", "type": "image/png"}]
+ },
+ {
+ "name": "New issue",
+ "short_name": "New",
+ "description": "Capture work now and file it online or offline.",
+ "url": "./?launch=new",
+ "icons": [{"src": "static/icons/stackchain-192.png", "sizes": "192x192", "type": "image/png"}]
+ },
+ {
+ "name": "Open Agenda",
+ "short_name": "Agenda",
+ "description": "Check every assigned deadline and continue the Agenda session.",
+ "url": "./?launch=agenda",
+ "icons": [{"src": "static/icons/stackchain-192.png", "sizes": "192x192", "type": "image/png"}]
+ }
+ ],
"share_target": {
"action": "./",
"method": "GET",
diff --git a/frontend/mobile-app-shortcuts.js b/frontend/mobile-app-shortcuts.js
new file mode 100644
index 0000000..ab2ad65
--- /dev/null
+++ b/frontend/mobile-app-shortcuts.js
@@ -0,0 +1,31 @@
+(function (root, factory) {
+ const api = factory();
+ if (typeof module === 'object' && module.exports) module.exports = api;
+ else root.mobileAppShortcuts = api;
+})(typeof self !== 'undefined' ? self : this, function () {
+ const ACTIONS = new Set(['continue', 'new', 'agenda']);
+
+ function parse(search) {
+ const action = new URLSearchParams(search || '').get('launch');
+ return ACTIONS.has(action) ? action : null;
+ }
+
+ function createController(options) {
+ const launchAction = parse(options.search);
+ let handled = false;
+
+ function action() { return launchAction; }
+
+ function run() {
+ if (!launchAction || handled) return null;
+ handled = true;
+ if (launchAction === 'continue') return options.continueWork();
+ if (launchAction === 'new') return options.newIssue();
+ return options.agenda();
+ }
+
+ return {action, run};
+ }
+
+ return {parse, createController};
+});
diff --git a/frontend/service-worker.js b/frontend/service-worker.js
index b37330a..8ca8e51 100644
--- a/frontend/service-worker.js
+++ b/frontend/service-worker.js
@@ -76,6 +76,7 @@ const SHELL = [
BASE + 'static/mobile-queue-launcher.js',
BASE + 'static/agenda-session-launcher.js',
BASE + 'static/mobile-launch.js',
+ BASE + 'static/mobile-app-shortcuts.js',
BASE + 'static/install-app.js',
BASE + 'static/mobile-device-setup.js',
BASE + 'static/mobile-search-viewport.js',
diff --git a/src/main.py b/src/main.py
index 809d17a..7982a48 100644
--- a/src/main.py
+++ b/src/main.py
@@ -1012,6 +1012,9 @@ def _share_target_login_redirect(request: Request) -> str:
for name in limits
if request.query_params.get(name)
]
+ launch = request.query_params.get("launch", "")
+ if launch in {"continue", "new", "agenda"}:
+ shared.append(("launch", launch))
if dashboard_auth.application_path(request) != "/" or not shared:
return "login"
continuation = f"./?{urlencode(shared)}"
diff --git a/tests/test_dashboard_auth.py b/tests/test_dashboard_auth.py
index be41879..e218d5e 100644
--- a/tests/test_dashboard_auth.py
+++ b/tests/test_dashboard_auth.py
@@ -876,6 +876,21 @@ async def test_anonymous_share_target_redirect_preserves_only_bounded_capture_fi
assert response.headers["cache-control"] == "no-store"
+@pytest.mark.anyio
+async def test_anonymous_app_shortcut_preserves_only_a_known_launch_action(access_control):
+ transport = httpx.ASGITransport(app=main.app)
+ async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
+ valid = await client.get("/", params={"launch": "agenda", "next": "https://evil.example"})
+ invalid = await client.get("/", params={"launch": "delete-account"})
+
+ assert valid.status_code == 303
+ assert parse_qs(urlsplit(valid.headers["location"]).query) == {
+ "continue": ["./?launch=agenda"]
+ }
+ assert invalid.status_code == 303
+ assert invalid.headers["location"] == "login"
+
+
@pytest.mark.anyio
async def test_oversized_share_target_is_not_carried_through_login(access_control):
transport = httpx.ASGITransport(app=main.app)
diff --git a/tests/test_mobile_launch.py b/tests/test_mobile_launch.py
index e062cd3..388bff9 100644
--- a/tests/test_mobile_launch.py
+++ b/tests/test_mobile_launch.py
@@ -8,6 +8,33 @@ from tests.dashboard_bundle import dashboard
MOBILE_LAUNCH = Path(__file__).resolve().parents[1] / "frontend" / "mobile-launch.js"
+APP_SHORTCUTS = Path(__file__).resolve().parents[1] / "frontend" / "mobile-app-shortcuts.js"
+
+
+def test_installed_app_shortcut_accepts_only_bounded_actions_and_runs_once():
+ script = f"""
+const shortcuts = require({json.dumps(str(APP_SHORTCUTS))});
+const calls = [];
+const controller = shortcuts.createController({{
+ search:'?launch=continue',
+ continueWork:() => calls.push('continue'),
+ newIssue:() => calls.push('new'),
+ agenda:() => calls.push('agenda'),
+}});
+Promise.resolve(controller.run()).then(() => controller.run()).then(() => {{
+ process.stdout.write(JSON.stringify({{
+ action:controller.action(), calls,
+ invalid:shortcuts.parse('?launch=delete-account'),
+ shared:shortcuts.parse('?launch=new&title=Shared'),
+ }}));
+}});
+"""
+ result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
+
+ assert result.returncode == 0, result.stderr
+ assert json.loads(result.stdout) == {
+ "action": "continue", "calls": ["continue"], "invalid": None, "shared": "new"
+ }
def test_mobile_launch_selects_priority_queue_without_overriding_saved_choice():
@@ -63,8 +90,13 @@ async def test_mobile_launch_progressively_discloses_secondary_controls():
assert 'id="work-settings-toggle"' in html
assert 'id="active-work-queue"' in html
assert '' in html
+ assert '' in html
service_worker = (Path(__file__).resolve().parents[1] / "frontend" / "service-worker.js").read_text()
assert "BASE + 'static/mobile-launch.js'" in service_worker
+ assert "BASE + 'static/mobile-app-shortcuts.js'" in service_worker
+ bootstrap = (Path(__file__).resolve().parents[1] / "frontend" / "dashboard.js").read_text()
+ assert "mobileAppShortcuts.createController" in bootstrap
+ assert bootstrap.index("await load();\n await appShortcut.run();") < bootstrap.index("contextPoller.start();")
assert "mobileLaunch.chooseFilter" in html
assert "agenda: counts.agenda" in html
assert "mobileLaunch.createDisclosure" in html
diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py
index a284c39..a94821d 100644
--- a/tests/test_service_worker.py
+++ b/tests/test_service_worker.py
@@ -788,6 +788,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
"/dashboard/static/mobile-queue-launcher.js",
"/dashboard/static/agenda-session-launcher.js",
"/dashboard/static/mobile-launch.js",
+ "/dashboard/static/mobile-app-shortcuts.js",
"/dashboard/static/install-app.js",
"/dashboard/static/mobile-device-setup.js",
"/dashboard/static/mobile-search-viewport.js",
diff --git a/tests/test_work_pages.py b/tests/test_work_pages.py
index 40b759a..eb38961 100644
--- a/tests/test_work_pages.py
+++ b/tests/test_work_pages.py
@@ -82,6 +82,26 @@ async def test_pwa_assets_expose_root_scoped_share_target_without_caching_api_da
"action": "./", "method": "GET", "enctype": "application/x-www-form-urlencoded",
"params": {"title": "title", "text": "text", "url": "url"},
}
+ assert manifest.json()["shortcuts"] == [
+ {
+ "name": "Continue work", "short_name": "Continue",
+ "description": "Resume the highest-priority mobile work flow.",
+ "url": "./?launch=continue",
+ "icons": [{"src": "static/icons/stackchain-192.png", "sizes": "192x192", "type": "image/png"}],
+ },
+ {
+ "name": "New issue", "short_name": "New",
+ "description": "Capture work now and file it online or offline.",
+ "url": "./?launch=new",
+ "icons": [{"src": "static/icons/stackchain-192.png", "sizes": "192x192", "type": "image/png"}],
+ },
+ {
+ "name": "Open Agenda", "short_name": "Agenda",
+ "description": "Check every assigned deadline and continue the Agenda session.",
+ "url": "./?launch=agenda",
+ "icons": [{"src": "static/icons/stackchain-192.png", "sizes": "192x192", "type": "image/png"}],
+ },
+ ]
assert worker.status_code == 200
assert worker.headers["content-type"].startswith("application/javascript")
assert worker.headers["service-worker-allowed"] == "/"