From bb1712279c7f760745fa4ace3fa5b7e2b975e9c1 Mon Sep 17 00:00:00 2001 From: timmy Date: Thu, 13 Aug 2026 12:26:27 +0000 Subject: [PATCH] feat: launch mobile work from PWA shortcuts (Closes #737) --- frontend/dashboard.js | 8 ++++++++ frontend/index.html | 1 + frontend/manifest.webmanifest | 23 +++++++++++++++++++++++ frontend/mobile-app-shortcuts.js | 31 +++++++++++++++++++++++++++++++ frontend/service-worker.js | 1 + src/main.py | 3 +++ tests/test_dashboard_auth.py | 15 +++++++++++++++ tests/test_mobile_launch.py | 32 ++++++++++++++++++++++++++++++++ tests/test_service_worker.py | 1 + tests/test_work_pages.py | 20 ++++++++++++++++++++ 10 files changed, 135 insertions(+) create mode 100644 frontend/mobile-app-shortcuts.js 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"] == "/" -- 2.43.0