feat: launch mobile work from PWA shortcuts (Closes #737)
This commit is contained in:
parent
e7d5196987
commit
bb1712279c
|
|
@ -533,6 +533,12 @@
|
||||||
}).catch(() => { /* The foreground localStorage outboxes remain available. */ });
|
}).catch(() => { /* The foreground localStorage outboxes remain available. */ });
|
||||||
}
|
}
|
||||||
const shareParams = new URLSearchParams(location.search);
|
const shareParams = new URLSearchParams(location.search);
|
||||||
|
const appShortcut = mobileAppShortcuts.createController({
|
||||||
|
search: location.search,
|
||||||
|
continueWork: () => mobileWorkEntry.open(),
|
||||||
|
newIssue: () => openCreateIssueSheet(),
|
||||||
|
agenda: () => openAgendaSession(),
|
||||||
|
});
|
||||||
const sharedLaunch = {
|
const sharedLaunch = {
|
||||||
title: shareParams.get('title') || '',
|
title: shareParams.get('title') || '',
|
||||||
text: shareParams.get('text') || '',
|
text: shareParams.get('text') || '',
|
||||||
|
|
@ -6234,6 +6240,8 @@
|
||||||
if (!deviceSetup) await (await ensureDeviceSetup()).open(event);
|
if (!deviceSetup) await (await ensureDeviceSetup()).open(event);
|
||||||
});
|
});
|
||||||
pushControllerReady.then(ensureDeviceSetup).catch(console.warn);
|
pushControllerReady.then(ensureDeviceSetup).catch(console.warn);
|
||||||
|
await load();
|
||||||
|
await appShortcut.run();
|
||||||
contextPoller.start();
|
contextPoller.start();
|
||||||
document.addEventListener('visibilitychange', () => {
|
document.addEventListener('visibilitychange', () => {
|
||||||
contextPoller.setVisible(!document.hidden);
|
contextPoller.setVisible(!document.hidden);
|
||||||
|
|
|
||||||
|
|
@ -1057,6 +1057,7 @@
|
||||||
<script src="static/mobile-queue-launcher.js"></script>
|
<script src="static/mobile-queue-launcher.js"></script>
|
||||||
<script src="static/agenda-session-launcher.js"></script>
|
<script src="static/agenda-session-launcher.js"></script>
|
||||||
<script src="static/mobile-launch.js"></script>
|
<script src="static/mobile-launch.js"></script>
|
||||||
|
<script src="static/mobile-app-shortcuts.js"></script>
|
||||||
<script src="static/install-app.js"></script>
|
<script src="static/install-app.js"></script>
|
||||||
<script src="static/mobile-device-setup.js"></script>
|
<script src="static/mobile-device-setup.js"></script>
|
||||||
<script src="static/mobile-search-viewport.js"></script>
|
<script src="static/mobile-search-viewport.js"></script>
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,29 @@
|
||||||
{"src": "static/icons/stackchain-192.png", "sizes": "192x192", "type": "image/png"},
|
{"src": "static/icons/stackchain-192.png", "sizes": "192x192", "type": "image/png"},
|
||||||
{"src": "static/icons/stackchain-512.png", "sizes": "512x512", "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": {
|
"share_target": {
|
||||||
"action": "./",
|
"action": "./",
|
||||||
"method": "GET",
|
"method": "GET",
|
||||||
|
|
|
||||||
31
frontend/mobile-app-shortcuts.js
Normal file
31
frontend/mobile-app-shortcuts.js
Normal file
|
|
@ -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};
|
||||||
|
});
|
||||||
|
|
@ -76,6 +76,7 @@ const SHELL = [
|
||||||
BASE + 'static/mobile-queue-launcher.js',
|
BASE + 'static/mobile-queue-launcher.js',
|
||||||
BASE + 'static/agenda-session-launcher.js',
|
BASE + 'static/agenda-session-launcher.js',
|
||||||
BASE + 'static/mobile-launch.js',
|
BASE + 'static/mobile-launch.js',
|
||||||
|
BASE + 'static/mobile-app-shortcuts.js',
|
||||||
BASE + 'static/install-app.js',
|
BASE + 'static/install-app.js',
|
||||||
BASE + 'static/mobile-device-setup.js',
|
BASE + 'static/mobile-device-setup.js',
|
||||||
BASE + 'static/mobile-search-viewport.js',
|
BASE + 'static/mobile-search-viewport.js',
|
||||||
|
|
|
||||||
|
|
@ -1012,6 +1012,9 @@ def _share_target_login_redirect(request: Request) -> str:
|
||||||
for name in limits
|
for name in limits
|
||||||
if request.query_params.get(name)
|
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:
|
if dashboard_auth.application_path(request) != "/" or not shared:
|
||||||
return "login"
|
return "login"
|
||||||
continuation = f"./?{urlencode(shared)}"
|
continuation = f"./?{urlencode(shared)}"
|
||||||
|
|
|
||||||
|
|
@ -876,6 +876,21 @@ async def test_anonymous_share_target_redirect_preserves_only_bounded_capture_fi
|
||||||
assert response.headers["cache-control"] == "no-store"
|
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
|
@pytest.mark.anyio
|
||||||
async def test_oversized_share_target_is_not_carried_through_login(access_control):
|
async def test_oversized_share_target_is_not_carried_through_login(access_control):
|
||||||
transport = httpx.ASGITransport(app=main.app)
|
transport = httpx.ASGITransport(app=main.app)
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,33 @@ from tests.dashboard_bundle import dashboard
|
||||||
|
|
||||||
|
|
||||||
MOBILE_LAUNCH = Path(__file__).resolve().parents[1] / "frontend" / "mobile-launch.js"
|
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():
|
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="work-settings-toggle"' in html
|
||||||
assert 'id="active-work-queue"' in html
|
assert 'id="active-work-queue"' in html
|
||||||
assert '<script src="static/mobile-launch.js"></script>' in html
|
assert '<script src="static/mobile-launch.js"></script>' in html
|
||||||
|
assert '<script src="static/mobile-app-shortcuts.js"></script>' in html
|
||||||
service_worker = (Path(__file__).resolve().parents[1] / "frontend" / "service-worker.js").read_text()
|
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-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 "mobileLaunch.chooseFilter" in html
|
||||||
assert "agenda: counts.agenda" in html
|
assert "agenda: counts.agenda" in html
|
||||||
assert "mobileLaunch.createDisclosure" in html
|
assert "mobileLaunch.createDisclosure" in html
|
||||||
|
|
|
||||||
|
|
@ -788,6 +788,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
||||||
"/dashboard/static/mobile-queue-launcher.js",
|
"/dashboard/static/mobile-queue-launcher.js",
|
||||||
"/dashboard/static/agenda-session-launcher.js",
|
"/dashboard/static/agenda-session-launcher.js",
|
||||||
"/dashboard/static/mobile-launch.js",
|
"/dashboard/static/mobile-launch.js",
|
||||||
|
"/dashboard/static/mobile-app-shortcuts.js",
|
||||||
"/dashboard/static/install-app.js",
|
"/dashboard/static/install-app.js",
|
||||||
"/dashboard/static/mobile-device-setup.js",
|
"/dashboard/static/mobile-device-setup.js",
|
||||||
"/dashboard/static/mobile-search-viewport.js",
|
"/dashboard/static/mobile-search-viewport.js",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
"action": "./", "method": "GET", "enctype": "application/x-www-form-urlencoded",
|
||||||
"params": {"title": "title", "text": "text", "url": "url"},
|
"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.status_code == 200
|
||||||
assert worker.headers["content-type"].startswith("application/javascript")
|
assert worker.headers["content-type"].startswith("application/javascript")
|
||||||
assert worker.headers["service-worker-allowed"] == "/"
|
assert worker.headers["service-worker-allowed"] == "/"
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user