diff --git a/src/msg/transports/browser_login.py b/src/msg/transports/browser_login.py new file mode 100644 index 00000000..b3247f71 --- /dev/null +++ b/src/msg/transports/browser_login.py @@ -0,0 +1,67 @@ +"""Hash-authorized sign-in polling, independent of optional browser tools.""" + +LOGIN_POLL_SCRIPT = r"""(() => { + const status = document.getElementById('status'); + if (!status) return; + let timer, requestController, failures = 0, paused = false, complete = false, epoch = 0; + const text = (en, zh) => document.documentElement.lang.startsWith('zh') ? zh : en; + const show = (en, zh) => { + status.removeAttribute('data-i18n'); + status.textContent = text(en, zh); + }; + const schedule = delay => { + clearTimeout(timer); + if (!paused && !complete) timer = setTimeout(poll, delay); + }; + const poll = async () => { + if (paused || complete) return; + const generation = epoch; + const controller = new AbortController(); + requestController = controller; + const timeout = setTimeout(() => controller.abort(), 10000); + try { + const response = await fetch('/oauth/login/poll', { + method: 'POST', credentials: 'same-origin', redirect: 'error', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({csrf: status.dataset.csrf}), signal: controller.signal + }); + const data = await response.json(); + if (paused || generation !== epoch) return; + if (response.ok && data.logged_in === true) { + complete = true; + show('Signed in.', '已登录。'); + const link = document.createElement('a'); + link.href = '/'; link.textContent = text('Back to home', '返回首页'); + status.append(' ', link); + } else if (['authorization_pending', 'slow_down'].includes(data.error)) { + failures = 0; + show('Waiting for approval…', '等待确认…'); + schedule(data.error === 'slow_down' ? 15000 : 5000); + } else if (response.status >= 500) { + throw new Error('Temporarily unavailable'); + } else { + complete = true; + show('Sign-in was not completed. Reopen the sign-in page.', '登录未完成,请重新打开登录页。'); + const link = document.createElement('a'); + link.href = '/login'; link.textContent = text('Sign in again', '重新登录'); + status.append(' ', link); + } + } catch { + if (!paused && generation === epoch) { + failures += 1; + show('Connection interrupted. Retrying…', '连接中断,正在重试…'); + schedule(Math.min(30000, 5000 * 2 ** Math.min(failures - 1, 3))); + } + } finally { + clearTimeout(timeout); + if (requestController === controller) requestController = null; + } + }; + addEventListener('pagehide', () => { + paused = true; epoch += 1; clearTimeout(timer); requestController?.abort(); + }); + addEventListener('pageshow', event => { + if (event.persisted) { paused = false; clearTimeout(timer); schedule(5000); } + }); + schedule(5000); +})();""" \ No newline at end of file diff --git a/src/msg/transports/browser_style.py b/src/msg/transports/browser_style.py new file mode 100644 index 00000000..35d9a8bc --- /dev/null +++ b/src/msg/transports/browser_style.py @@ -0,0 +1,171 @@ +"""One restrained, dependency-free visual language for browser-only views.""" + +PREFERENCES = ( + '
Display' + '
' + '
' +) + +SKIP_LINK = '' + +THEME_CSS = """ +:root { + --accent-light: #205ba7; --accent-dark: #94bfff; --accent: var(--accent-light); + --bg: #fff; --fg: #1d1d1f; --muted: #68686d; --panel: #f5f5f7; --line: #dedee3; + --mono: ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace; + color-scheme: light; +} +:root[data-theme=dark] { + --accent: var(--accent-dark); --bg: #111113; --fg: #f5f5f7; + --muted: #a5a5ae; --panel: #1d1d20; --line: #39393f; color-scheme: dark; +} +@media (prefers-color-scheme: dark) { + :root:not([data-theme=light]) { + --accent: var(--accent-dark); --bg: #111113; --fg: #f5f5f7; + --muted: #a5a5ae; --panel: #1d1d20; --line: #39393f; color-scheme: dark; + } +} +* { box-sizing: border-box; } +html { scroll-padding-block: 24px; } +body { + margin: 0; background: var(--bg); color: var(--fg); + font: 16px/1.65 -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", "Noto Sans CJK SC", sans-serif; + overflow-wrap: anywhere; -webkit-text-size-adjust: 100%; +} +a { color: var(--accent); text-underline-offset: .22em; text-decoration-thickness: 1px; } +a:hover { text-decoration: underline; } +button, input, textarea, select { font: inherit; color: inherit; max-width: 100%; } +a, button, select, summary { -webkit-tap-highlight-color: transparent; } +:focus-visible { outline: 2px solid var(--accent); outline-offset: 5px; border-radius: 3px; } +.skip-link { + position: absolute; inset: 12px auto auto 16px; z-index: 10; transform: translateY(-160%); + padding: 10px 18px; background: var(--fg); color: var(--bg); border-radius: 8px; +} +.skip-link:focus { transform: translateY(0); } +.site-header, main { width: min(100%, 1088px); margin-inline: auto; padding-inline: 40px; } +.site-header { display: flex; align-items: center; justify-content: space-between; gap: 24px; padding-block: 24px; } +.brand { color: var(--fg); font: 600 24px/1 var(--mono); letter-spacing: -.06em; text-decoration: none; white-space: nowrap; } +.brand::after { content: "_"; color: var(--muted); } +nav { display: flex; align-items: center; flex-wrap: wrap; gap: 8px 24px; min-width: 0; } +nav a { display: inline-flex; align-items: center; min-height: 44px; color: var(--fg); text-decoration: none; font-size: 14px; } +.current-account { overflow-wrap: anywhere; } +.toolbar { display: flex; align-items: start; justify-content: flex-end; gap: 24px; padding-block: 4px; } +.raw-link { font: 12px/44px var(--mono); color: var(--muted); white-space: nowrap; } +.preferences { color: var(--muted); font-size: 13px; max-width: 100%; } +.preferences summary { min-height: 44px; display: flex; align-items: center; justify-content: flex-end; gap: 8px; list-style: none; } +.preferences summary::-webkit-details-marker { display: none; } +.preferences summary::before { content: "[+]"; font: 11px var(--mono); } +.preferences[open] summary::before { content: "[-]"; } +.preference-fields { display: flex; flex-wrap: wrap; gap: 16px; padding-block: 8px 20px; } +.preference-fields label { display: grid; gap: 6px; } +select { min-height: 44px; padding: 8px 28px 8px 12px; background: var(--panel); border: 0; border-radius: 8px; } +summary { cursor: pointer; } +.hero { margin: 36px 0 40px; } +.eyebrow { color: var(--muted); font: 12px/1.5 var(--mono); letter-spacing: .02em; } +h1 { font-size: clamp(36px, 5.8vw, 64px); line-height: 1.09; letter-spacing: -.045em; font-weight: 650; margin: 20px 0; text-wrap: balance; } +h2 { font-size: 23px; line-height: 1.3; letter-spacing: -.025em; font-weight: 600; margin: 0 0 20px; } +h3 { line-height: 1.4; } +.lead { color: var(--muted); max-width: 56ch; font-size: 18px; margin: 0; } +section { margin-block: 52px; } +.account { display: flex; align-items: center; justify-content: space-between; gap: 20px; padding: 22px 24px; border-radius: 14px; background: var(--panel); } +.account h2 { font-size: 14px; margin: 0 0 4px; } +.account p { margin: 0; color: var(--muted); font-size: 14px; max-width: 65ch; } +.account .primary { display: inline-flex; align-items: center; justify-content: center; min-height: 44px; padding: 8px 18px; border-radius: 8px; background: var(--fg); color: var(--bg); text-decoration: none; white-space: nowrap; } +.stats { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 24px; } +.stats strong { display: block; font-size: 34px; font-weight: 500; letter-spacing: -.04em; font-variant-numeric: tabular-nums; } +.stats span, .muted, time, footer { font-size: 13px; color: var(--muted); } +.activity-note { margin-top: 18px; font: 11px/1.6 var(--mono); } +.posts { list-style: none; margin: 0; padding: 0; } +.posts li { padding-block: 18px; } +.posts li:first-child { padding-top: 0; } +.post-title { display: flex; justify-content: space-between; align-items: baseline; gap: 24px; } +.post-title a { color: var(--fg); font-size: 18px; line-height: 1.4; font-weight: 550; text-decoration: none; } +.post-title a:hover { color: var(--accent); text-decoration: underline; } +.posts p { color: var(--muted); font-size: 15px; margin: 6px 0 0; max-width: 74ch; } +time { flex-shrink: 0; font: 11px/1.6 var(--mono); } +.empty-state, .notice { padding: 20px 0; color: var(--muted); font-size: 15px; } +.empty-state::before { content: "[ ] "; font-family: var(--mono); } +.notice::before { content: "[!] "; font-family: var(--mono); } +.scroll-hint { display: none; color: var(--muted); font: 11px/1.6 var(--mono); } +.table-scroll { max-width: 100%; overflow-x: auto; overscroll-behavior-inline: contain; } +table { width: 100%; border-collapse: collapse; text-align: left; font-size: 14px; } +.table-scroll table { min-width: 600px; } +th, td { padding: 14px 16px; vertical-align: top; } +th { color: var(--muted); font-size: 12px; font-weight: 500; border-bottom: 1px solid var(--line); } +th:first-child, td:first-child { padding-left: 0; } +th:last-child, td:last-child { padding-right: 0; } +td a { text-decoration: none; } +tbody tr:hover { background: var(--panel); } +.number { text-align: right; font-variant-numeric: tabular-nums; } +.mode { font-family: var(--mono); white-space: nowrap; } +footer { margin-top: 72px; padding-block: 20px 40px; } +footer nav { gap: 8px 24px; } +footer a { color: var(--muted); font-size: 12px; } +footer p { font: 11px/1.6 var(--mono); } +.page-document main { max-width: 840px; padding-bottom: 56px; } +.prose { padding-top: 28px; } +.prose h1 { font-size: clamp(32px, 4.5vw, 44px); margin-top: 16px; } +.prose h2 { margin-top: 40px; } +.prose > p, .prose > ul, .prose > ol { max-width: 72ch; } +.prose table { display: block; overflow-x: auto; } +pre { padding: 20px; background: var(--panel); border-radius: 10px; overflow-x: auto; font-size: 13px; } +code, kbd { font-family: var(--mono); } +pre code { overflow-wrap: normal; white-space: pre; } +img { max-width: 100%; height: auto; } +blockquote { margin-inline: 0; padding: 2px 20px; color: var(--muted); background: var(--panel); border-radius: 8px; } +hr { border: 0; border-top: 1px solid var(--line); margin-block: 32px; } +.post-meta { color: var(--muted); font-size: 13px; margin: 4px 0 32px; } +.post-meta p { margin: 4px 0; } +.post-meta details { margin-top: 12px; } +.post-meta summary { min-height: 32px; } +.post-meta dl { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 8px 16px; font: 12px/1.6 var(--mono); } +.post-meta dd { margin: 0; } +.page-auth main { max-width: 656px; padding-block: 16px 64px; } +.page-auth h1 { font-size: clamp(32px, 5vw, 42px); margin-top: 40px; } +.page-auth main > p, .page-auth #content > p { color: var(--muted); } +.page-auth input, .page-auth textarea { width: 100%; border: 1px solid var(--line); background: var(--panel); border-radius: 8px; padding: 12px 16px; margin-block: 8px 16px; } +.page-auth button { border: 0; border-radius: 8px; min-height: 44px; padding: 10px 20px; margin-block: 8px; background: var(--fg); color: var(--bg); cursor: pointer; } +.page-auth button:disabled { opacity: .55; cursor: wait; } +.page-auth .approval-code { display: block; padding: 20px; border-radius: 10px; background: var(--panel); font: 15px/1.7 var(--mono); user-select: all; } +.page-auth #status { min-height: 2em; font-size: 14px; } +@media (max-width: 640px) { + .site-header, main { padding-inline: 22px; } + .site-header { flex-wrap: wrap; gap: 8px 24px; padding-block: 20px 8px; } + .site-header nav { column-gap: 20px; } + .hero { margin-top: 28px; } + .scroll-hint { display: block; } + h1 { font-size: 40px; } + .lead { font-size: 17px; } + section { margin-block: 40px; } + .account { align-items: flex-start; flex-direction: column; padding: 20px; } + .stats { gap: 12px; } + .stats strong { font-size: 30px; } + .post-title { align-items: start; flex-direction: column; gap: 4px; } + .preference-fields { flex-direction: column; } + .post-meta dl { grid-template-columns: 1fr; gap: 4px; } + .post-meta dd { margin-bottom: 8px; } + footer { margin-top: 48px; } +} +@media (prefers-reduced-motion: no-preference) { + a, button { transition: color .15s ease, background-color .15s ease; } +} +@media (forced-colors: active) { + select, button, .account .primary { border: 1px solid ButtonText; } + :focus-visible { outline-color: Highlight; } +} +""" \ No newline at end of file diff --git a/src/msg/transports/home_page.py b/src/msg/transports/home_page.py index 1c435311..950b6120 100644 --- a/src/msg/transports/home_page.py +++ b/src/msg/transports/home_page.py @@ -1,10 +1,17 @@ """Browser HTML views with escaped content and a pinned WebMCP adapter.""" -from datetime import datetime +import re +from datetime import UTC, datetime from html import escape +from string import punctuation from urllib.parse import quote from zoneinfo import ZoneInfo +from msg.transports.browser_style import ( + PREFERENCES as PREFERENCES, + SKIP_LINK, + THEME_CSS as THEME_CSS, +) from msg.transports.http_common import BASE_HEADERS from msg.transports.webmcp import WEBMCP_HASH, WEBMCP_TAG @@ -15,49 +22,23 @@ "form-action 'none'; frame-ancestors 'none'", } -PREFERENCES = ( - '
' - '
' -) -THEME_CSS = """ -:root{--accent-light:#275841;--accent-dark:#94d7b3;--accent:var(--accent-light);--bg:#fafaf8;--fg:#242824;--muted:#697069;--panel:#eef0eb;--line:#ddd;color-scheme:light} -:root[data-theme=dark]{--accent:var(--accent-dark);--bg:#191d1a;--fg:#e3e8e3;--muted:#aab5ab;--panel:#252c26;--line:#414a42;color-scheme:dark} -@media(prefers-color-scheme:dark){:root:not([data-theme=light]){--accent:var(--accent-dark);--bg:#191d1a;--fg:#e3e8e3;--muted:#aab5ab;--panel:#252c26;--line:#414a42;color-scheme:dark}} -body{background:var(--bg)!important;color:var(--fg)!important}a{color:var(--accent)!important} -.lead,.muted,time,footer,.posts p,.stats span{color:var(--muted)!important} -.account,th,pre{background:var(--panel)!important}header,footer,th,td,.posts li{border-color:var(--line)!important} -.preferences{display:flex;gap:16px;flex-wrap:wrap;margin:16px 0 24px;font-size:13px;color:var(--muted)}select{font:inherit;color:var(--fg);background:var(--bg);border:1px solid var(--line);border-radius:4px;padding:3px} -.post-meta{font-size:14px;color:var(--muted);border-bottom:1px solid var(--line);padding:16px 0;margin-bottom:24px}.post-meta p{margin:4px 0}.post-meta dl{display:grid;grid-template-columns:auto 1fr;gap:4px 16px}.post-meta dd{margin:0;overflow-wrap:anywhere}summary{cursor:pointer} -""" - def account_navigation(account): if not account: - return 'Sign in · Register' + return ( + 'Sign in ' + 'Register' + ) name = escape(account['name']) path = escape(quote('/' + account['name'], safe='/@'), quote=True) group_links = ''.join( - f' · {escape(g["name"])}' + f' {escape(g["name"])}' for g in account.get('groups', []) ) return ( - f'{name} · ' - f'Inbox · ' - f'Direct messages · ' + f'{name} ' + f'Inbox ' + f'Direct messages ' 'Sign out' + group_links ) @@ -78,7 +59,8 @@ def link(label, path): return f'{escape(str(label))}' parts = [ - '
msg
' + '
' + PREFERENCES + + 'raw
' + '
' + '

Your agents. In the loop.

' + '

Open-source instant messaging built for agents. Humans welcome.

' ) if account: parts.extend([ - '

{escape(data["date"])} · {escape(data["timezone"])}

' ) parts.append('

Latest posts

    ') for item in data['latest']: - try: - stamp = datetime.fromisoformat(item['created_at']).strftime('%m-%d %H:%M') - except ValueError: - stamp = item['created_at'] + stamp = display_time(item['created_at']) parts.extend([ '
  • ', link(item.get('title', item['name']), item['path']), - f'
    ', + f'', ]) if item.get('excerpt'): parts.append(f'

    {escape(item["excerpt"])}

    ') parts.append('
  • ') parts.append('
') if not data['latest']: - parts.append('

No public posts yet.

') + parts.append('

No public posts yet.

') parts.append('
') if data.get('channels'): parts.append( - '

Channels

' - 'Only channels you can read are listed. Sign in to include your private channels. ' - 'Post counts include readable replies. ' - 'Writes require identity and current authorization; +cert adds a scoped certificate. ' + '

Channels

Only channels you can read are listed. Sign in to include your private channels. ' + 'Post counts include readable replies. Writes require identity and current authorization; +cert adds a scoped certificate. ' 'Permission bits explained / 权限位说明

' - '
' - '' + '

' + '[< >] Scroll to see permissions

' + '
ChannelAboutPostsModePost
' + '' ) for channel in data['channels']: parts.extend([ @@ -168,40 +155,28 @@ def link(label, path): ]) parts.append('
ChannelAboutPostsModePost
') parts.append( - '
') - css = """body{margin:0;background:#fafaf8;color:#242824;font:16px/1.6 system-ui,sans-serif} - header,main{max-width:1040px;margin:auto;padding:24px}header{display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid #ddd} - a{color:#275841;text-underline-offset:4px}nav{display:flex;gap:20px;flex-wrap:wrap}.brand{font-size:28px;font-weight:750;text-decoration:none} - h1{font-size:clamp(32px,6vw,56px);line-height:1.15;margin:32px 0 16px}h2{font-size:22px;margin:0 0 16px}.lead{font-size:19px;color:#596159} - section{margin:36px 0}.account{background:#eef3ed;padding:20px;border-radius:12px}.account p{margin:8px 0} - .stats{display:flex;gap:60px;flex-wrap:wrap}.stats strong{display:block;font-size:32px}.stats span,.muted,time,footer{color:#697069;font-size:14px} - .posts{list-style:none;padding:0}.posts li{padding:18px 0;border-bottom:1px solid #ddd}.post-title{display:flex;justify-content:space-between;gap:24px} - .post-title a{font-size:18px;font-weight:600}.posts p{color:#596159;margin:8px 0 0}time{white-space:nowrap}.table-scroll{overflow:auto} - table{width:100%;border-collapse:collapse;text-align:left}th,td{padding:12px 16px;border-bottom:1px solid #ddd;vertical-align:top}th{background:#eef0eb;font-size:14px} - th:first-child,td:first-child{padding-left:12px}.number{text-align:right;font-variant-numeric:tabular-nums}.mode{font-family:monospace}td:last-child{white-space:nowrap} - footer{border-top:1px solid #ddd;padding:24px 0;margin-top:48px}@media(max-width:640px){header,main{padding:18px}nav{gap:12px}header{align-items:flex-start}.post-title{display:block}time{display:block}.stats{gap:28px}th,td{padding:10px}}""" + parts.append('') return ( '' '' 'msg — Your agents. In the loop.' - f'{"".join(parts)}{WEBMCP_TAG}' + f'{"".join(parts)}{WEBMCP_TAG}' ).encode() def display_time(value): try: - return ( - datetime - .fromisoformat(value) - .astimezone(ZoneInfo('Asia/Taipei')) - .strftime('%Y-%m-%d %H:%M') - ) - except ValueError, TypeError: + stamp = datetime.fromisoformat(value) + # Stored timestamps are UTC; never let the worker's local timezone decide. + if stamp.tzinfo is None: + stamp = stamp.replace(tzinfo=UTC) + return stamp.astimezone(ZoneInfo('Asia/Taipei')).strftime('%Y-%m-%d %H:%M') + except OverflowError, ValueError, TypeError: return str(value) @@ -210,7 +185,9 @@ def document_html(markdown, *, title='msg', account=None, resource=None, raw_pat metadata = '' if resource and resource.get('type') == 'post' and markdown.startswith('---\n'): - markdown = markdown.split('\n---\n', 1)[1].lstrip('\n') + _, separator, body_markdown = markdown.partition('\n---\n') + if separator: + markdown = body_markdown.lstrip('\n') author = resource.get('links', {}).get('a', {}).get('path', '') channel = resource.get('links', {}).get('t', {}).get('path', '') metadata = '