nandi/frqpublic Fork 0
939d2ac
Commits
Clone
git clone https://git.rickub.com/nandi/frq.git
git clone ssh://git@rickub.com/nandi/frq.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Sign in with Bluesky on the phone, browser and all

The browser leg works, and it needed no app link and no change at the
broker — which is the opposite of what I said last time. The repo had
already worked this out for the APK that was deleted: `return_to` is
`http://127.0.0.1:<port>` on a phone exactly as on a desktop, and the
custom scheme is not part of the OAuth flow at all. It is how the app gets
back in front of Chrome afterwards, claimed by our own manifest and never
sent anywhere. `capture-html` already had the Android branch written.

So the phone binds a loopback listener, and `dart:io` has one — no plugin,
and Android's cleartext ban does not apply because we are the server and a
browser treats 127.0.0.1 as a secure context. What is left is opening the
browser, which is the one thing Dart cannot do alone: `frq.io/open-url!`,
named for the result like the rest of the seam, over the portal on the
desktop and an https VIEW intent here. externalApplication and not an
in-app web view on purpose — a login the app could see inside is the thing
OAuth exists to avoid.

Verified end to end on a Pixel 6a against auth.freeq.at: Chrome opened at
the PDS, the handoff came back through the loopback, `frq://auth` raised
the app, and the broker token reached session.edn. A restart then resumed
with no browser at all and the server re-joined the account's channels.

Two things found on the way. The request body was read through
`utf8.decoder`, which throws CastStream inside a stream callback where
nothing catches it — the same generic ClojureDart loses that `frq.net.dart`
splits its own lines to avoid — so the response was never written and the
page sat on "Finishing sign-in…" for ever. And the ScrollController from
the last commit was one per key in a global map: Flutter builds a new
subtree before unmounting the old one whenever the tree changes shape, so
two live views shared it and it asserted, which took the whole screen. It
is `:managed` now, owned and disposed by the widget that scrolls.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nandi committed 2026-09-12T01:14:47-07:00 Browse files
939d2ac parent: c6506f2
modified common/frq/io.cljc +10 -0
@@ -104,6 +104,16 @@
104104
105105 ;; ------------------------------------------------------------------- time
106106
107+(defn open-url!
108+ "Hand `url` to whatever shows web pages here, and say whether that worked.
109+
110+ Named for the result and not the mechanism, like the rest of this seam: the
111+ desktop shells out to the portal and the phone asks Android to pick an
112+ activity, and neither is the other's business. A false answer is not fatal —
113+ the OAuth screen shows the URL so it can be opened by hand."
114+ [url]
115+ (boolean (call :open-url! [url])))
116+
107117 (defn wall-nanos [] (call :wall-nanos []))
108118 (defn mono-nanos [] (call :mono-nanos []))
109119
@@ -104,6 +104,16 @@
104 104
105 ;; ------------------------------------------------------------------- time105 ;; ------------------------------------------------------------------- time
106 106
107+(defn open-url!
108+ "Hand `url` to whatever shows web pages here, and say whether that worked.
109+
110+ Named for the result and not the mechanism, like the rest of this seam: the
111+ desktop shells out to the portal and the phone asks Android to pick an
112+ activity, and neither is the other's business. A false answer is not fatal —
113+ the OAuth screen shows the URL so it can be opened by hand."
114+ [url]
115+ (boolean (call :open-url! [url])))
116+
107 (defn wall-nanos [] (call :wall-nanos []))117 (defn wall-nanos [] (call :wall-nanos []))
108 (defn mono-nanos [] (call :mono-nanos []))118 (defn mono-nanos [] (call :mono-nanos []))
109 119
modified common/frq/oauth/core.cljc +36 -0
@@ -94,3 +94,39 @@
9494 :nick (atproto/json-str body "nick")
9595 :did (atproto/json-str body "did")
9696 :handle (or (atproto/json-str body "handle") "")}))
97+
98+
99+;; ------------------------------------------------------------ the capture page
100+
101+(defn capture-html
102+ "The page the browser lands on with the handoff in its fragment. Its one job
103+ is to POST that fragment back, since a fragment never reaches a server.
104+
105+ `return-url` is the deep link back to the app, or nil where there is nowhere
106+ to go on a desktop the browser sits beside the app and the reader switches
107+ windows. On Android the app is behind the browser and something has to bring
108+ it forward: the page tries the link on its own, and offers it as a tap for
109+ the case Chrome refuses a scheme it was not asked for by hand."
110+ [return-url]
111+ (str "<!doctype html><meta charset=utf-8><title>frq</title>"
112+ "<body style=\"font:15px system-ui;background:#242424;color:#fff;padding:40px\">"
113+ "<p id=m>Finishing sign-in…</p>"
114+ (when return-url
115+ (str "<p><a id=b href=\"" return-url "\" hidden "
116+ "style=\"display:inline-block;padding:12px 20px;border-radius:8px;"
117+ "background:#5a7fd0;color:#fff;text-decoration:none\">Return to frq</a></p>"))
118+ "<script>"
119+ "var h=location.hash.replace(/^#/,'');"
120+ "var p=new URLSearchParams(h).get('oauth')||h.replace(/^oauth=/,'');"
121+ "if(!p){document.getElementById('m').textContent='No sign-in payload in this URL.';}"
122+ "else{fetch('/capture',{method:'POST',body:p})"
123+ ".then(function(){document.getElementById('m').textContent="
124+ (if return-url "'Signed in — returning to frq…';" "'Signed in — you can close this tab.';")
125+ (when return-url
126+ (str "var b=document.getElementById('b');b.hidden=false;"
127+ ;; Chrome answers a scripted navigation to a scheme of its own
128+ ;; only sometimes; the link is there for when it does not.
129+ "location.href=b.href;"))
130+ "})"
131+ ".catch(function(e){document.getElementById('m').textContent='Handoff failed: '+e;});}"
132+ "</script></body>"))
@@ -94,3 +94,39 @@
94 :nick (atproto/json-str body "nick")94 :nick (atproto/json-str body "nick")
95 :did (atproto/json-str body "did")95 :did (atproto/json-str body "did")
96 :handle (or (atproto/json-str body "handle") "")}))96 :handle (or (atproto/json-str body "handle") "")}))
97+
98+
99+;; ------------------------------------------------------------ the capture page
100+
101+(defn capture-html
102+ "The page the browser lands on with the handoff in its fragment. Its one job
103+ is to POST that fragment back, since a fragment never reaches a server.
104+
105+ `return-url` is the deep link back to the app, or nil where there is nowhere
106+ to go on a desktop the browser sits beside the app and the reader switches
107+ windows. On Android the app is behind the browser and something has to bring
108+ it forward: the page tries the link on its own, and offers it as a tap for
109+ the case Chrome refuses a scheme it was not asked for by hand."
110+ [return-url]
111+ (str "<!doctype html><meta charset=utf-8><title>frq</title>"
112+ "<body style=\"font:15px system-ui;background:#242424;color:#fff;padding:40px\">"
113+ "<p id=m>Finishing sign-in…</p>"
114+ (when return-url
115+ (str "<p><a id=b href=\"" return-url "\" hidden "
116+ "style=\"display:inline-block;padding:12px 20px;border-radius:8px;"
117+ "background:#5a7fd0;color:#fff;text-decoration:none\">Return to frq</a></p>"))
118+ "<script>"
119+ "var h=location.hash.replace(/^#/,'');"
120+ "var p=new URLSearchParams(h).get('oauth')||h.replace(/^oauth=/,'');"
121+ "if(!p){document.getElementById('m').textContent='No sign-in payload in this URL.';}"
122+ "else{fetch('/capture',{method:'POST',body:p})"
123+ ".then(function(){document.getElementById('m').textContent="
124+ (if return-url "'Signed in — returning to frq…';" "'Signed in — you can close this tab.';")
125+ (when return-url
126+ (str "var b=document.getElementById('b');b.hidden=false;"
127+ ;; Chrome answers a scripted navigation to a scheme of its own
128+ ;; only sometimes; the link is there for when it does not.
129+ "location.href=b.href;"))
130+ "})"
131+ ".catch(function(e){document.getElementById('m').textContent='Handoff failed: '+e;});}"
132+ "</script></body>"))
modified flutter/android/app/src/main/AndroidManifest.xml +25 -1
@@ -10,7 +10,7 @@
1010 <activity
1111 android:name=".MainActivity"
1212 android:exported="true"
13- android:launchMode="singleTop"
13+ android:launchMode="singleTask"
1414 android:taskAffinity=""
1515 android:theme="@style/LaunchTheme"
1616 android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
@@ -28,6 +28,23 @@
2828 <action android:name="android.intent.action.MAIN"/>
2929 <category android:name="android.intent.category.LAUNCHER"/>
3030 </intent-filter>
31+ <!-- What brings frq back to the front when the browser has
32+ finished signing in. Chrome is on top of the app rather than
33+ beside it, so the capture page navigates here once it has
34+ handed the payload to the loopback listener.
35+
36+ The broker never sees this: `return_to` is the loopback URL on
37+ both desktop and phone, and the scheme is claimed by this
38+ manifest and used only on this device. Nothing is read out of
39+ the intent either — the tokens arrived over the socket, and
40+ this only has to raise the window. singleTask above is what
41+ makes that the running app rather than a second copy. -->
42+ <intent-filter>
43+ <action android:name="android.intent.action.VIEW"/>
44+ <category android:name="android.intent.category.DEFAULT"/>
45+ <category android:name="android.intent.category.BROWSABLE"/>
46+ <data android:scheme="frq" android:host="auth"/>
47+ </intent-filter>
3148 </activity>
3249 <!-- Don't delete the meta-data below.
3350 This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
@@ -41,6 +58,13 @@
4158
4259 In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
4360 <queries>
61+ <!-- url_launcher asks Android who can show an https page. Without
62+ this, package visibility on Android 11+ answers "nobody" and the
63+ sign-in URL silently fails to open. -->
64+ <intent>
65+ <action android:name="android.intent.action.VIEW"/>
66+ <data android:scheme="https"/>
67+ </intent>
4468 <intent>
4569 <action android:name="android.intent.action.PROCESS_TEXT"/>
4670 <data android:mimeType="text/plain"/>
@@ -10,7 +10,7 @@
10 <activity10 <activity
11 android:name=".MainActivity"11 android:name=".MainActivity"
12 android:exported="true"12 android:exported="true"
13- android:launchMode="singleTop"13+ android:launchMode="singleTask"
14 android:taskAffinity=""14 android:taskAffinity=""
15 android:theme="@style/LaunchTheme"15 android:theme="@style/LaunchTheme"
16 android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"16 android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
@@ -28,6 +28,23 @@
28 <action android:name="android.intent.action.MAIN"/>28 <action android:name="android.intent.action.MAIN"/>
29 <category android:name="android.intent.category.LAUNCHER"/>29 <category android:name="android.intent.category.LAUNCHER"/>
30 </intent-filter>30 </intent-filter>
31+ <!-- What brings frq back to the front when the browser has
32+ finished signing in. Chrome is on top of the app rather than
33+ beside it, so the capture page navigates here once it has
34+ handed the payload to the loopback listener.
35+
36+ The broker never sees this: `return_to` is the loopback URL on
37+ both desktop and phone, and the scheme is claimed by this
38+ manifest and used only on this device. Nothing is read out of
39+ the intent either — the tokens arrived over the socket, and
40+ this only has to raise the window. singleTask above is what
41+ makes that the running app rather than a second copy. -->
42+ <intent-filter>
43+ <action android:name="android.intent.action.VIEW"/>
44+ <category android:name="android.intent.category.DEFAULT"/>
45+ <category android:name="android.intent.category.BROWSABLE"/>
46+ <data android:scheme="frq" android:host="auth"/>
47+ </intent-filter>
31 </activity>48 </activity>
32 <!-- Don't delete the meta-data below.49 <!-- Don't delete the meta-data below.
33 This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->50 This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
@@ -41,6 +58,13 @@
41 58
42 In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->59 In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
43 <queries>60 <queries>
61+ <!-- url_launcher asks Android who can show an https page. Without
62+ this, package visibility on Android 11+ answers "nobody" and the
63+ sign-in URL silently fails to open. -->
64+ <intent>
65+ <action android:name="android.intent.action.VIEW"/>
66+ <data android:scheme="https"/>
67+ </intent>
44 <intent>68 <intent>
45 <action android:name="android.intent.action.PROCESS_TEXT"/>69 <action android:name="android.intent.action.PROCESS_TEXT"/>
46 <data android:mimeType="text/plain"/>70 <data android:mimeType="text/plain"/>
modified flutter/linux/flutter/generated_plugin_registrant.cc +4 -0
@@ -6,6 +6,10 @@
66
77 #include "generated_plugin_registrant.h"
88
9+#include <url_launcher_linux/url_launcher_plugin.h>
910
1011 void fl_register_plugins(FlPluginRegistry* registry) {
12+ g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar =
13+ fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
14+ url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);
1115 }
@@ -6,6 +6,10 @@
6 6
7 #include "generated_plugin_registrant.h"7 #include "generated_plugin_registrant.h"
8 8
9+#include <url_launcher_linux/url_launcher_plugin.h>
9 10
10 void fl_register_plugins(FlPluginRegistry* registry) {11 void fl_register_plugins(FlPluginRegistry* registry) {
12+ g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar =
13+ fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
14+ url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);
11 }15 }
modified flutter/linux/flutter/generated_plugins.cmake +1 -0
@@ -3,6 +3,7 @@
33 #
44
55 list(APPEND FLUTTER_PLUGIN_LIST
6+ url_launcher_linux
67 )
78
89 list(APPEND FLUTTER_FFI_PLUGIN_LIST
@@ -3,6 +3,7 @@
3 #3 #
4 4
5 list(APPEND FLUTTER_PLUGIN_LIST5 list(APPEND FLUTTER_PLUGIN_LIST
6+ url_launcher_linux
6 )7 )
7 8
8 list(APPEND FLUTTER_FFI_PLUGIN_LIST9 list(APPEND FLUTTER_FFI_PLUGIN_LIST
modified flutter/pubspec.lock +78 -1
@@ -139,6 +139,11 @@ packages:
139139 description: flutter
140140 source: sdk
141141 version: "0.0.0"
142+ flutter_web_plugins:
143+ dependency: transitive
144+ description: flutter
145+ source: sdk
146+ version: "0.0.0"
142147 hooks:
143148 dependency: transitive
144149 description:
@@ -400,6 +405,70 @@ packages:
400405 url: "https://pub.dev"
401406 source: hosted
402407 version: "1.4.0"
408+ url_launcher:
409+ dependency: "direct main"
410+ description:
411+ name: url_launcher
412+ sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8
413+ url: "https://pub.dev"
414+ source: hosted
415+ version: "6.3.2"
416+ url_launcher_android:
417+ dependency: transitive
418+ description:
419+ name: url_launcher_android
420+ sha256: "611e87fb320b70d1dd721dc46af89c98aceccea9b31fde49e084591414e0c610"
421+ url: "https://pub.dev"
422+ source: hosted
423+ version: "6.3.33"
424+ url_launcher_ios:
425+ dependency: transitive
426+ description:
427+ name: url_launcher_ios
428+ sha256: "8faa1aab294f1ab4040b43660c887b0418d5fa4f0cffef76a484e6aa1092eb4a"
429+ url: "https://pub.dev"
430+ source: hosted
431+ version: "6.4.2"
432+ url_launcher_linux:
433+ dependency: transitive
434+ description:
435+ name: url_launcher_linux
436+ sha256: "10f86fef4c2c43563fa6c211ff9cf757adf4d3ab762c56bd430664a947d70cd0"
437+ url: "https://pub.dev"
438+ source: hosted
439+ version: "3.2.3"
440+ url_launcher_macos:
441+ dependency: transitive
442+ description:
443+ name: url_launcher_macos
444+ sha256: "5e835a3b869c2d70325349c81c5a45c28e20791265b67b2669da6b08c5cd5201"
445+ url: "https://pub.dev"
446+ source: hosted
447+ version: "3.2.6"
448+ url_launcher_platform_interface:
449+ dependency: transitive
450+ description:
451+ name: url_launcher_platform_interface
452+ sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029"
453+ url: "https://pub.dev"
454+ source: hosted
455+ version: "2.3.2"
456+ url_launcher_web:
457+ dependency: transitive
458+ description:
459+ name: url_launcher_web
460+ sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34"
461+ url: "https://pub.dev"
462+ source: hosted
463+ version: "2.4.3"
464+ url_launcher_windows:
465+ dependency: transitive
466+ description:
467+ name: url_launcher_windows
468+ sha256: "6c5ad3f22cd4c38e089b81963b3cd7bb83b111b2df5dce008bb066162f42e429"
469+ url: "https://pub.dev"
470+ source: hosted
471+ version: "3.1.6"
403472 vector_math:
404473 dependency: transitive
405474 description:
@@ -416,6 +485,14 @@ packages:
416485 url: "https://pub.dev"
417486 source: hosted
418487 version: "15.3.0"
488+ web:
489+ dependency: transitive
490+ description:
491+ name: web
492+ sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
493+ url: "https://pub.dev"
494+ source: hosted
495+ version: "1.1.1"
419496 xdg_directories:
420497 dependency: transitive
421498 description:
@@ -434,4 +511,4 @@ packages:
434511 version: "3.1.4"
435512 sdks:
436513 dart: ">=3.13.0 <4.0.0"
437- flutter: ">=3.38.4"
514+ flutter: ">=3.44.0"
@@ -139,6 +139,11 @@ packages:
139 description: flutter139 description: flutter
140 source: sdk140 source: sdk
141 version: "0.0.0"141 version: "0.0.0"
142+ flutter_web_plugins:
143+ dependency: transitive
144+ description: flutter
145+ source: sdk
146+ version: "0.0.0"
142 hooks:147 hooks:
143 dependency: transitive148 dependency: transitive
144 description:149 description:
@@ -400,6 +405,70 @@ packages:
400 url: "https://pub.dev"405 url: "https://pub.dev"
401 source: hosted406 source: hosted
402 version: "1.4.0"407 version: "1.4.0"
408+ url_launcher:
409+ dependency: "direct main"
410+ description:
411+ name: url_launcher
412+ sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8
413+ url: "https://pub.dev"
414+ source: hosted
415+ version: "6.3.2"
416+ url_launcher_android:
417+ dependency: transitive
418+ description:
419+ name: url_launcher_android
420+ sha256: "611e87fb320b70d1dd721dc46af89c98aceccea9b31fde49e084591414e0c610"
421+ url: "https://pub.dev"
422+ source: hosted
423+ version: "6.3.33"
424+ url_launcher_ios:
425+ dependency: transitive
426+ description:
427+ name: url_launcher_ios
428+ sha256: "8faa1aab294f1ab4040b43660c887b0418d5fa4f0cffef76a484e6aa1092eb4a"
429+ url: "https://pub.dev"
430+ source: hosted
431+ version: "6.4.2"
432+ url_launcher_linux:
433+ dependency: transitive
434+ description:
435+ name: url_launcher_linux
436+ sha256: "10f86fef4c2c43563fa6c211ff9cf757adf4d3ab762c56bd430664a947d70cd0"
437+ url: "https://pub.dev"
438+ source: hosted
439+ version: "3.2.3"
440+ url_launcher_macos:
441+ dependency: transitive
442+ description:
443+ name: url_launcher_macos
444+ sha256: "5e835a3b869c2d70325349c81c5a45c28e20791265b67b2669da6b08c5cd5201"
445+ url: "https://pub.dev"
446+ source: hosted
447+ version: "3.2.6"
448+ url_launcher_platform_interface:
449+ dependency: transitive
450+ description:
451+ name: url_launcher_platform_interface
452+ sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029"
453+ url: "https://pub.dev"
454+ source: hosted
455+ version: "2.3.2"
456+ url_launcher_web:
457+ dependency: transitive
458+ description:
459+ name: url_launcher_web
460+ sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34"
461+ url: "https://pub.dev"
462+ source: hosted
463+ version: "2.4.3"
464+ url_launcher_windows:
465+ dependency: transitive
466+ description:
467+ name: url_launcher_windows
468+ sha256: "6c5ad3f22cd4c38e089b81963b3cd7bb83b111b2df5dce008bb066162f42e429"
469+ url: "https://pub.dev"
470+ source: hosted
471+ version: "3.1.6"
403 vector_math:472 vector_math:
404 dependency: transitive473 dependency: transitive
405 description:474 description:
@@ -416,6 +485,14 @@ packages:
416 url: "https://pub.dev"485 url: "https://pub.dev"
417 source: hosted486 source: hosted
418 version: "15.3.0"487 version: "15.3.0"
488+ web:
489+ dependency: transitive
490+ description:
491+ name: web
492+ sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
493+ url: "https://pub.dev"
494+ source: hosted
495+ version: "1.1.1"
419 xdg_directories:496 xdg_directories:
420 dependency: transitive497 dependency: transitive
421 description:498 description:
@@ -434,4 +511,4 @@ packages:
434 version: "3.1.4"511 version: "3.1.4"
435 sdks:512 sdks:
436 dart: ">=3.13.0 <4.0.0"513 dart: ">=3.13.0 <4.0.0"
437- flutter: ">=3.38.4"514+ flutter: ">=3.44.0"
modified flutter/pubspec.yaml +4 -0
@@ -37,6 +37,10 @@ dependencies:
3737 path_provider: ^2.1.6
3838 ed25519_edwards: ^0.3.2
3939 crypto: ^3.0.7
40+ # Opening the system browser, which is the only part of the OAuth handoff
41+ # dart:io cannot do on its own — the loopback listener that catches the
42+ # answer is plain dart:io.
43+ url_launcher: ^6.3.1
4044 dev_dependencies:
4145 flutter_test:
4246 sdk: flutter
@@ -37,6 +37,10 @@ dependencies:
37 path_provider: ^2.1.637 path_provider: ^2.1.6
38 ed25519_edwards: ^0.3.238 ed25519_edwards: ^0.3.2
39 crypto: ^3.0.739 crypto: ^3.0.7
40+ # Opening the system browser, which is the only part of the OAuth handoff
41+ # dart:io cannot do on its own — the loopback listener that catches the
42+ # answer is plain dart:io.
43+ url_launcher: ^6.3.1
40 dev_dependencies:44 dev_dependencies:
41 flutter_test:45 flutter_test:
42 sdk: flutter46 sdk: flutter
modified flutter/src/frq/hiccup.cljd +19 -24
@@ -143,28 +143,14 @@
143143 [node]
144144 (prose? node))
145145
146-;; A ScrollController per `:scroll-key`, and what was last done with it.
146+;; What was last done with each `:scroll-key`.
147147 ;;
148-;; Kept outside the widget tree because `render` is a plain function, not a
149-;; `f/widget` with state of its own: the tree is rebuilt from scratch on every
150-;; repaint, and a controller made during a build would start at the top each
151-;; time and forget where the reader was.
152-(defonce ^:private scroll-controllers (atom {}))
148+;; The controller itself is not here it belongs to the widget, see `:scroll`
149+;; below. This outlives it on purpose: whether a reader had been taken to the
150+;; end is a fact about the conversation, not about the widget currently
151+;; showing it, and it has to survive the rebuild that replaces one.
153152 (defonce ^:private scroll-marks (atom {}))
154153
155-(defn- scroll-controller-for
156- "The one controller for `k`, made the first time it is asked for.
157-
158- Named apart from the text-editing `controller-for` above deliberately: they
159- are the same idea for two different widgets, and when they shared a name the
160- compiler took the second and left every entry on screen calling it with an
161- argument too many."
162- [k]
163- (or (get @scroll-controllers k)
164- (let [c (m/ScrollController)]
165- (swap! scroll-controllers assoc k c)
166- c)))
167-
168154 (defn- end-ward!
169155 "Put `k` at the end after this frame, when it should be.
170156
@@ -565,11 +551,20 @@
565551 ;; "competing" and then draws nothing.
566552 :scroll
567553 (let [k (str (:scroll-key p))
568- ctrl (when (seq k) (scroll-controller-for k))]
569- (when ctrl (end-ward! k ctrl (:scroll-to-bottom p)))
570- (m/SingleChildScrollView
571- .controller ctrl
572- .child (col (dbl (:spacing p) 0.0) (body node))))
554+ token (:scroll-to-bottom p)]
555+ (f/widget
556+ ;; `:managed`, so the controller belongs to this widget and is
557+ ;; disposed with it. One controller per key in a global map was the
558+ ;; obvious thing and the wrong thing: Flutter builds a new subtree
559+ ;; before unmounting the old one whenever the tree changes shape, so
560+ ;; two live scroll views briefly shared it and it asserted —
561+ ;; "ScrollController attached to multiple scroll views", which took
562+ ;; the whole screen.
563+ :managed [ctrl (m/ScrollController)]
564+ :let [_ (end-ward! k ctrl token)]
565+ (m/SingleChildScrollView
566+ .controller ctrl
567+ .child (col (dbl (:spacing p) 0.0) (body node)))))
573568
574569 ;; A tag this backend has not grown yet still shows its children — which
575570 ;; is what libvidya did and what jolt-cosmic kept. The marker is here so
@@ -143,28 +143,14 @@
143 [node]143 [node]
144 (prose? node))144 (prose? node))
145 145
146-;; A ScrollController per `:scroll-key`, and what was last done with it.146+;; What was last done with each `:scroll-key`.
147 ;;147 ;;
148-;; Kept outside the widget tree because `render` is a plain function, not a148+;; The controller itself is not here it belongs to the widget, see `:scroll`
149-;; `f/widget` with state of its own: the tree is rebuilt from scratch on every149+;; below. This outlives it on purpose: whether a reader had been taken to the
150-;; repaint, and a controller made during a build would start at the top each150+;; end is a fact about the conversation, not about the widget currently
151-;; time and forget where the reader was.151+;; showing it, and it has to survive the rebuild that replaces one.
152-(defonce ^:private scroll-controllers (atom {}))
153 (defonce ^:private scroll-marks (atom {}))152 (defonce ^:private scroll-marks (atom {}))
154 153
155-(defn- scroll-controller-for
156- "The one controller for `k`, made the first time it is asked for.
157-
158- Named apart from the text-editing `controller-for` above deliberately: they
159- are the same idea for two different widgets, and when they shared a name the
160- compiler took the second and left every entry on screen calling it with an
161- argument too many."
162- [k]
163- (or (get @scroll-controllers k)
164- (let [c (m/ScrollController)]
165- (swap! scroll-controllers assoc k c)
166- c)))
167-
168 (defn- end-ward!154 (defn- end-ward!
169 "Put `k` at the end after this frame, when it should be.155 "Put `k` at the end after this frame, when it should be.
170 156
@@ -565,11 +551,20 @@
565 ;; "competing" and then draws nothing.551 ;; "competing" and then draws nothing.
566 :scroll552 :scroll
567 (let [k (str (:scroll-key p))553 (let [k (str (:scroll-key p))
568- ctrl (when (seq k) (scroll-controller-for k))]554+ token (:scroll-to-bottom p)]
569- (when ctrl (end-ward! k ctrl (:scroll-to-bottom p)))555+ (f/widget
570- (m/SingleChildScrollView556+ ;; `:managed`, so the controller belongs to this widget and is
571- .controller ctrl557+ ;; disposed with it. One controller per key in a global map was the
572- .child (col (dbl (:spacing p) 0.0) (body node))))558+ ;; obvious thing and the wrong thing: Flutter builds a new subtree
559+ ;; before unmounting the old one whenever the tree changes shape, so
560+ ;; two live scroll views briefly shared it and it asserted —
561+ ;; "ScrollController attached to multiple scroll views", which took
562+ ;; the whole screen.
563+ :managed [ctrl (m/ScrollController)]
564+ :let [_ (end-ward! k ctrl token)]
565+ (m/SingleChildScrollView
566+ .controller ctrl
567+ .child (col (dbl (:spacing p) 0.0) (body node)))))
573 568
574 ;; A tag this backend has not grown yet still shows its children — which569 ;; A tag this backend has not grown yet still shows its children — which
575 ;; is what libvidya did and what jolt-cosmic kept. The marker is here so570 ;; is what libvidya did and what jolt-cosmic kept. The marker is here so
modified flutter/src/frq/io/dart.cljd +10 -0
@@ -17,6 +17,7 @@
1717 shape it should take rather than as code known to compile."
1818 (:require ["dart:convert" :as conv]
1919 ["dart:io" :as io]
20+ ["package:url_launcher/url_launcher.dart" :as launcher]
2021 [frq.io :as fio]))
2122
2223 (defonce ^:private uptime
@@ -74,6 +75,15 @@
7475 [dir]
7576 (fio/install!
7677 {:getenv (fn [n] (get (.-environment io/Platform) n))
78+ ;; Android picks the activity: whatever answers an https VIEW intent,
79+ ;; which is the browser the reader already uses and is already signed in
80+ ;; to. `externalApplication` and not an in-app web view on purpose — a
81+ ;; login the app could see inside is the thing OAuth exists to avoid.
82+ :open-url! (fn [url]
83+ (launcher/launchUrl
84+ (Uri.parse (str url))
85+ .mode launcher/LaunchMode.externalApplication)
86+ true)
7787 :config-dir (fn [] dir)
7888 :file-exists? (fn [p] (.existsSync (io/File. p)))
7989 :directory? (fn [p] (.existsSync (io/Directory. p)))
@@ -17,6 +17,7 @@
17 shape it should take rather than as code known to compile."17 shape it should take rather than as code known to compile."
18 (:require ["dart:convert" :as conv]18 (:require ["dart:convert" :as conv]
19 ["dart:io" :as io]19 ["dart:io" :as io]
20+ ["package:url_launcher/url_launcher.dart" :as launcher]
20 [frq.io :as fio]))21 [frq.io :as fio]))
21 22
22 (defonce ^:private uptime23 (defonce ^:private uptime
@@ -74,6 +75,15 @@
74 [dir]75 [dir]
75 (fio/install!76 (fio/install!
76 {:getenv (fn [n] (get (.-environment io/Platform) n))77 {:getenv (fn [n] (get (.-environment io/Platform) n))
78+ ;; Android picks the activity: whatever answers an https VIEW intent,
79+ ;; which is the browser the reader already uses and is already signed in
80+ ;; to. `externalApplication` and not an in-app web view on purpose — a
81+ ;; login the app could see inside is the thing OAuth exists to avoid.
82+ :open-url! (fn [url]
83+ (launcher/launchUrl
84+ (Uri.parse (str url))
85+ .mode launcher/LaunchMode.externalApplication)
86+ true)
77 :config-dir (fn [] dir)87 :config-dir (fn [] dir)
78 :file-exists? (fn [p] (.existsSync (io/File. p)))88 :file-exists? (fn [p] (.existsSync (io/File. p)))
79 :directory? (fn [p] (.existsSync (io/Directory. p)))89 :directory? (fn [p] (.existsSync (io/Directory. p)))
modified flutter/src/frq/main.cljd +45 -4
@@ -23,6 +23,9 @@
2323 [frq.hiccup :as h]
2424 [frq.theme :as t]
2525 [frq.io.dart :as host]
26+ ;; The seam, not the implementation above: `open-url!` is asked for
27+ ;; the same way the shared half asks for it.
28+ [frq.io :as fio]
2629 [frq.crypto.dart :as crypto-dart]
2730 [frq.net.dart :as net]
2831 [frq.atproto.dart :as atproto]
@@ -38,6 +41,7 @@
3841 [frq.rooms :as rooms]
3942 [frq.members :as members]
4043 [frq.oauth.core :as oauth]
44+ [frq.oauth.dart :as oauth-dart]
4145 [frq.irc.parse :as irc]
4246 [frq.irc.handshake :as handshake]))
4347
@@ -196,10 +200,22 @@
196200 ;; it does.
197201 (store/save-session! tokens)
198202 (assoc tokens :kind :web-token)))
199- (throw (ex-info (str "Bluesky sign-in opens a browser, which the phone "
200- "cannot catch the answer to yet — sign in with an "
201- "app password instead")
202- {}))))
203+ ;; No saved token: the browser leg. `frq.oauth.dart` binds the loopback,
204+ ;; and what comes back through it is the first broker token.
205+ (let [tokens (await (oauth-dart/await-callback!
206+ oauth/default-broker
207+ (str @cells/form-handle)
208+ (fn [url]
209+ (reset! cells/login-url url)
210+ (reset! cells/status
211+ (if (fio/open-url! url)
212+ "Waiting for the browser…"
213+ "Open the sign-in link below to continue"))
214+ nil)))]
215+ (reset! cells/login-url nil)
216+ (reset! cells/broker-token (:broker-token tokens))
217+ (store/save-session! tokens)
218+ (assoc tokens :kind :web-token))))
203219
204220 (defn ^:async sign-in!
205221 "Fill `cells/session` for the mode that was chosen, or say why not.
@@ -219,6 +235,20 @@
219235 (do (reset! cells/status "Signing in…")
220236 (atproto/create-session @cells/form-handle
221237 @cells/form-app-password)))))
238+ ;; An authenticated connection still needs a nick the DID is the
239+ ;; identity, the nick is only what the channel calls you. Same order the
240+ ;; desktop picks it in: what the broker said, else the first label of the
241+ ;; handle, else whatever is in the box.
242+ (let [sess @cells/session
243+ nick (or (:nick sess)
244+ (first (.split (str (or (:handle sess) "")) "."))
245+ nil)]
246+ (when (seq (str (or (:handle sess) "")))
247+ (reset! cells/form-handle (:handle sess)))
248+ (when (seq (str (or nick "")))
249+ (reset! cells/form-nick nick))
250+ ;; The password did its work at the PDS; do not keep it.
251+ (reset! cells/form-app-password ""))
222252 true
223253 (catch Object e
224254 (reset! cells/session nil)
@@ -417,6 +447,17 @@
417447 ;; Before any widget is built: a cell that changes before its watch is on
418448 ;; is a change the screen never hears about.
419449 (watch-cells!)
450+ ;; A sign-in that already happened. Only the durable broker token comes
451+ ;; back the connection mints a fresh web-token from it so this is not
452+ ;; a session, it is the means to ask for one.
453+ (when-let [saved (store/load-session)]
454+ (reset! cells/broker-token (:broker-token saved))
455+ (when (seq (str (or (:handle saved) "")))
456+ (reset! cells/form-handle (:handle saved)))
457+ (when (seq (str (or (:nick saved) "")))
458+ (reset! cells/form-nick (:nick saved)))
459+ (reset! cells/auth-mode :bluesky)
460+ (reset! cells/status (str "Signed in as " (:handle saved) " — Connect to resume")))
420461 ;; What the shared screen calls. The desktop installs frq.state's
421462 ;; reducers here; this installs the phone's.
422463 (actions/install!
@@ -23,6 +23,9 @@
23 [frq.hiccup :as h]23 [frq.hiccup :as h]
24 [frq.theme :as t]24 [frq.theme :as t]
25 [frq.io.dart :as host]25 [frq.io.dart :as host]
26+ ;; The seam, not the implementation above: `open-url!` is asked for
27+ ;; the same way the shared half asks for it.
28+ [frq.io :as fio]
26 [frq.crypto.dart :as crypto-dart]29 [frq.crypto.dart :as crypto-dart]
27 [frq.net.dart :as net]30 [frq.net.dart :as net]
28 [frq.atproto.dart :as atproto]31 [frq.atproto.dart :as atproto]
@@ -38,6 +41,7 @@
38 [frq.rooms :as rooms]41 [frq.rooms :as rooms]
39 [frq.members :as members]42 [frq.members :as members]
40 [frq.oauth.core :as oauth]43 [frq.oauth.core :as oauth]
44+ [frq.oauth.dart :as oauth-dart]
41 [frq.irc.parse :as irc]45 [frq.irc.parse :as irc]
42 [frq.irc.handshake :as handshake]))46 [frq.irc.handshake :as handshake]))
43 47
@@ -196,10 +200,22 @@
196 ;; it does.200 ;; it does.
197 (store/save-session! tokens)201 (store/save-session! tokens)
198 (assoc tokens :kind :web-token)))202 (assoc tokens :kind :web-token)))
199- (throw (ex-info (str "Bluesky sign-in opens a browser, which the phone "203+ ;; No saved token: the browser leg. `frq.oauth.dart` binds the loopback,
200- "cannot catch the answer to yet — sign in with an "204+ ;; and what comes back through it is the first broker token.
201- "app password instead")205+ (let [tokens (await (oauth-dart/await-callback!
202- {}))))206+ oauth/default-broker
207+ (str @cells/form-handle)
208+ (fn [url]
209+ (reset! cells/login-url url)
210+ (reset! cells/status
211+ (if (fio/open-url! url)
212+ "Waiting for the browser…"
213+ "Open the sign-in link below to continue"))
214+ nil)))]
215+ (reset! cells/login-url nil)
216+ (reset! cells/broker-token (:broker-token tokens))
217+ (store/save-session! tokens)
218+ (assoc tokens :kind :web-token))))
203 219
204 (defn ^:async sign-in!220 (defn ^:async sign-in!
205 "Fill `cells/session` for the mode that was chosen, or say why not.221 "Fill `cells/session` for the mode that was chosen, or say why not.
@@ -219,6 +235,20 @@
219 (do (reset! cells/status "Signing in…")235 (do (reset! cells/status "Signing in…")
220 (atproto/create-session @cells/form-handle236 (atproto/create-session @cells/form-handle
221 @cells/form-app-password)))))237 @cells/form-app-password)))))
238+ ;; An authenticated connection still needs a nick the DID is the
239+ ;; identity, the nick is only what the channel calls you. Same order the
240+ ;; desktop picks it in: what the broker said, else the first label of the
241+ ;; handle, else whatever is in the box.
242+ (let [sess @cells/session
243+ nick (or (:nick sess)
244+ (first (.split (str (or (:handle sess) "")) "."))
245+ nil)]
246+ (when (seq (str (or (:handle sess) "")))
247+ (reset! cells/form-handle (:handle sess)))
248+ (when (seq (str (or nick "")))
249+ (reset! cells/form-nick nick))
250+ ;; The password did its work at the PDS; do not keep it.
251+ (reset! cells/form-app-password ""))
222 true252 true
223 (catch Object e253 (catch Object e
224 (reset! cells/session nil)254 (reset! cells/session nil)
@@ -417,6 +447,17 @@
417 ;; Before any widget is built: a cell that changes before its watch is on447 ;; Before any widget is built: a cell that changes before its watch is on
418 ;; is a change the screen never hears about.448 ;; is a change the screen never hears about.
419 (watch-cells!)449 (watch-cells!)
450+ ;; A sign-in that already happened. Only the durable broker token comes
451+ ;; back the connection mints a fresh web-token from it so this is not
452+ ;; a session, it is the means to ask for one.
453+ (when-let [saved (store/load-session)]
454+ (reset! cells/broker-token (:broker-token saved))
455+ (when (seq (str (or (:handle saved) "")))
456+ (reset! cells/form-handle (:handle saved)))
457+ (when (seq (str (or (:nick saved) "")))
458+ (reset! cells/form-nick (:nick saved)))
459+ (reset! cells/auth-mode :bluesky)
460+ (reset! cells/status (str "Signed in as " (:handle saved) " — Connect to resume")))
420 ;; What the shared screen calls. The desktop installs frq.state's461 ;; What the shared screen calls. The desktop installs frq.state's
421 ;; reducers here; this installs the phone's.462 ;; reducers here; this installs the phone's.
422 (actions/install!463 (actions/install!
added flutter/src/frq/oauth/dart.cljd +86 -0
new file mode 100644
@@ -0,0 +1,86 @@
1+(ns frq.oauth.dart
2+ "The browser handoff, caught on the phone.
3+
4+ The same shape as the desktop's, and for the same reason: freeq's broker owns
5+ the OAuth dance with the user's PDS, so a real browser has to render a real
6+ login page, and the only question left is how the answer gets back. It comes
7+ back to a loopback listener here exactly as it does on a desktop
8+ `return_to` is `http://127.0.0.1:<port>` either way, and the broker is never
9+ told which kind of machine it is talking to.
10+
11+ Two things are the phone's own. Chrome is in front of the app rather than
12+ beside it, so the capture page carries `frq://auth` to bring it forward
13+ a scheme the manifest claims and nothing outside this device ever sees.
14+ And Android's cleartext ban does not apply: this is the server, and a
15+ browser treats `http://127.0.0.1` as a secure context.
16+
17+ `dart:io` has the listener, so there is no plugin here. Opening the browser
18+ is the one thing Dart cannot do alone `frq.io/open-url!` answers that."
19+ (:require ["dart:io" :as io]
20+ ["dart:async" :as async]
21+ [frq.io :as host]
22+ [frq.oauth.core :as core]))
23+
24+(def ^:private return-url
25+ "What brings the app back to the front once the browser is done. The activity
26+ is `singleTask`, so this raises the app that is already running rather than
27+ starting a second copy of it."
28+ "frq://auth")
29+
30+(defn ^:async await-callback!
31+ "Serve the loopback capture until the browser posts the handoff back.
32+
33+ `on-url` is called with the login URL once the port is known that is what
34+ the caller opens and shows. Returns the tokens.
35+
36+ Port 0, not a walk up from 7390 as the desktop does: the desktop picks from a
37+ known range because its `return_to` may have to be whitelisted by hand, and
38+ here the broker is handed whatever the kernel gave us in the same breath."
39+ [broker handle on-url]
40+ (let [server (await (io/HttpServer.bind (.-loopbackIPv4 io/InternetAddress) 0))
41+ port (.-port server)
42+ done (async/Completer)]
43+ (try
44+ (on-url (core/login-url broker handle (str "http://127.0.0.1:" port)))
45+ (.listen server
46+ (fn [req]
47+ (let [resp (.-response req)]
48+ (if (= "POST" (.-method req))
49+ ;; The fragment the page posted back. A POST carrying
50+ ;; nothing usable is not the end of the wait the real
51+ ;; handoff may still arrive so the listener stays up and
52+ ;; only a good payload completes.
53+ ;;
54+ ;; The bytes are gathered by hand rather than run through
55+ ;; `utf8.decoder`. A request is a Stream<Uint8List> and
56+ ;; the decoder wants a Stream<List<int>>, which is the
57+ ;; same generic ClojureDart loses through a dynamic call
58+ ;; that `frq.net.dart` splits its own lines to avoid:
59+ ;; the transform throws CastStream, inside a stream
60+ ;; callback, where nothing catches it so the response
61+ ;; was never written and the browser sat on "Finishing
62+ ;; sign-in" for ever.
63+ (let [chunks (atom [])]
64+ (.listen req
65+ (fn [chunk] (swap! chunks into chunk) nil)
66+ .onDone
67+ (fn []
68+ (let [tokens (try (core/tokens-of
69+ (.trim (host/utf8-string @chunks)))
70+ (catch Object _ nil))]
71+ (.write resp (if tokens "ok" "bad payload"))
72+ (.then (.close resp)
73+ (fn [_]
74+ (when (and tokens
75+ (not (.-isCompleted done)))
76+ (.complete done tokens))
77+ nil)))
78+ nil)))
79+ (do
80+ (.set (.-headers resp) "content-type"
81+ "text/html; charset=utf-8")
82+ (.write resp (core/capture-html return-url))
83+ (.close resp)))
84+ nil)))
85+ (await (.-future done))
86+ (finally (await (.close server .force true))))))
new file mode 100644
@@ -0,0 +1,86 @@
1+(ns frq.oauth.dart
2+ "The browser handoff, caught on the phone.
3+
4+ The same shape as the desktop's, and for the same reason: freeq's broker owns
5+ the OAuth dance with the user's PDS, so a real browser has to render a real
6+ login page, and the only question left is how the answer gets back. It comes
7+ back to a loopback listener here exactly as it does on a desktop
8+ `return_to` is `http://127.0.0.1:<port>` either way, and the broker is never
9+ told which kind of machine it is talking to.
10+
11+ Two things are the phone's own. Chrome is in front of the app rather than
12+ beside it, so the capture page carries `frq://auth` to bring it forward
13+ a scheme the manifest claims and nothing outside this device ever sees.
14+ And Android's cleartext ban does not apply: this is the server, and a
15+ browser treats `http://127.0.0.1` as a secure context.
16+
17+ `dart:io` has the listener, so there is no plugin here. Opening the browser
18+ is the one thing Dart cannot do alone `frq.io/open-url!` answers that."
19+ (:require ["dart:io" :as io]
20+ ["dart:async" :as async]
21+ [frq.io :as host]
22+ [frq.oauth.core :as core]))
23+
24+(def ^:private return-url
25+ "What brings the app back to the front once the browser is done. The activity
26+ is `singleTask`, so this raises the app that is already running rather than
27+ starting a second copy of it."
28+ "frq://auth")
29+
30+(defn ^:async await-callback!
31+ "Serve the loopback capture until the browser posts the handoff back.
32+
33+ `on-url` is called with the login URL once the port is known that is what
34+ the caller opens and shows. Returns the tokens.
35+
36+ Port 0, not a walk up from 7390 as the desktop does: the desktop picks from a
37+ known range because its `return_to` may have to be whitelisted by hand, and
38+ here the broker is handed whatever the kernel gave us in the same breath."
39+ [broker handle on-url]
40+ (let [server (await (io/HttpServer.bind (.-loopbackIPv4 io/InternetAddress) 0))
41+ port (.-port server)
42+ done (async/Completer)]
43+ (try
44+ (on-url (core/login-url broker handle (str "http://127.0.0.1:" port)))
45+ (.listen server
46+ (fn [req]
47+ (let [resp (.-response req)]
48+ (if (= "POST" (.-method req))
49+ ;; The fragment the page posted back. A POST carrying
50+ ;; nothing usable is not the end of the wait the real
51+ ;; handoff may still arrive so the listener stays up and
52+ ;; only a good payload completes.
53+ ;;
54+ ;; The bytes are gathered by hand rather than run through
55+ ;; `utf8.decoder`. A request is a Stream<Uint8List> and
56+ ;; the decoder wants a Stream<List<int>>, which is the
57+ ;; same generic ClojureDart loses through a dynamic call
58+ ;; that `frq.net.dart` splits its own lines to avoid:
59+ ;; the transform throws CastStream, inside a stream
60+ ;; callback, where nothing catches it so the response
61+ ;; was never written and the browser sat on "Finishing
62+ ;; sign-in" for ever.
63+ (let [chunks (atom [])]
64+ (.listen req
65+ (fn [chunk] (swap! chunks into chunk) nil)
66+ .onDone
67+ (fn []
68+ (let [tokens (try (core/tokens-of
69+ (.trim (host/utf8-string @chunks)))
70+ (catch Object _ nil))]
71+ (.write resp (if tokens "ok" "bad payload"))
72+ (.then (.close resp)
73+ (fn [_]
74+ (when (and tokens
75+ (not (.-isCompleted done)))
76+ (.complete done tokens))
77+ nil)))
78+ nil)))
79+ (do
80+ (.set (.-headers resp) "content-type"
81+ "text/html; charset=utf-8")
82+ (.write resp (core/capture-html return-url))
83+ (.close resp)))
84+ nil)))
85+ (await (.-future done))
86+ (finally (await (.close server .force true))))))
modified src/frq/io/jolt.clj +2 -0
@@ -6,6 +6,7 @@
66 backend. Every desktop entry point requires this before `frq.app`."
77 (:require [clojure.string :as str]
88 [frq.io :as io]
9+ [frq.platform :as platform]
910 [jolt.host :as host]))
1011
1112 (def ^:private zone
@@ -64,6 +65,7 @@
6465
6566 (io/install!
6667 {:getenv host/getenv
68+ :open-url! platform/open-url!
6769 :config-dir config-dir
6870 :file-exists? host/file-exists?
6971 :directory? host/directory?
@@ -6,6 +6,7 @@
6 backend. Every desktop entry point requires this before `frq.app`."6 backend. Every desktop entry point requires this before `frq.app`."
7 (:require [clojure.string :as str]7 (:require [clojure.string :as str]
8 [frq.io :as io]8 [frq.io :as io]
9+ [frq.platform :as platform]
9 [jolt.host :as host]))10 [jolt.host :as host]))
10 11
11 (def ^:private zone12 (def ^:private zone
@@ -64,6 +65,7 @@
64 65
65 (io/install!66 (io/install!
66 {:getenv host/getenv67 {:getenv host/getenv
68+ :open-url! platform/open-url!
67 :config-dir config-dir69 :config-dir config-dir
68 :file-exists? host/file-exists?70 :file-exists? host/file-exists?
69 :directory? host/directory?71 :directory? host/directory?
modified src/frq/oauth.clj +4 -32
@@ -38,38 +38,10 @@
3838
3939 ;; ------------------------------------------------------------------ capture
4040
41-(defn- capture-html
42- "The page the browser lands on with the handoff in its fragment. Its one job
43- is to POST that fragment back, since a fragment never reaches a server.
44-
45- `return-url` is the deep link back to the app, or nil where there is nowhere
46- to go — on a desktop the browser sits beside the app and the reader switches
47- windows. On Android the app is behind the browser and something has to bring
48- it forward: the page tries the link on its own, and offers it as a tap for
49- the case Chrome refuses a scheme it was not asked for by hand."
50- [return-url]
51- (str "<!doctype html><meta charset=utf-8><title>frq</title>"
52- "<body style=\"font:15px system-ui;background:#242424;color:#fff;padding:40px\">"
53- "<p id=m>Finishing sign-in…</p>"
54- (when return-url
55- (str "<p><a id=b href=\"" return-url "\" hidden "
56- "style=\"display:inline-block;padding:12px 20px;border-radius:8px;"
57- "background:#5a7fd0;color:#fff;text-decoration:none\">Return to frq</a></p>"))
58- "<script>"
59- "var h=location.hash.replace(/^#/,'');"
60- "var p=new URLSearchParams(h).get('oauth')||h.replace(/^oauth=/,'');"
61- "if(!p){document.getElementById('m').textContent='No sign-in payload in this URL.';}"
62- "else{fetch('/capture',{method:'POST',body:p})"
63- ".then(function(){document.getElementById('m').textContent="
64- (if return-url "'Signed in — returning to frq…';" "'Signed in — you can close this tab.';")
65- (when return-url
66- (str "var b=document.getElementById('b');b.hidden=false;"
67- ;; Chrome answers a scripted navigation to a scheme of its own
68- ;; only sometimes; the link is there for when it does not.
69- "location.href=b.href;"))
70- "})"
71- ".catch(function(e){document.getElementById('m').textContent='Handoff failed: '+e;});}"
72- "</script></body>"))
41+(def capture-html
42+ "Moved to `frq.oauth.core`: it is a string, and the phone serves the same one
43+ from a `dart:io` HttpServer. Re-exported so callers did not move."
44+ core/capture-html)
7345
7446 (defn- respond! [fd body content-type]
7547 (let [head (str "HTTP/1.1 200 OK\r\nContent-Type: " content-type
@@ -38,38 +38,10 @@
38 38
39 ;; ------------------------------------------------------------------ capture39 ;; ------------------------------------------------------------------ capture
40 40
41-(defn- capture-html41+(def capture-html
42- "The page the browser lands on with the handoff in its fragment. Its one job42+ "Moved to `frq.oauth.core`: it is a string, and the phone serves the same one
43- is to POST that fragment back, since a fragment never reaches a server.43+ from a `dart:io` HttpServer. Re-exported so callers did not move."
44-44+ core/capture-html)
45- `return-url` is the deep link back to the app, or nil where there is nowhere
46- to go — on a desktop the browser sits beside the app and the reader switches
47- windows. On Android the app is behind the browser and something has to bring
48- it forward: the page tries the link on its own, and offers it as a tap for
49- the case Chrome refuses a scheme it was not asked for by hand."
50- [return-url]
51- (str "<!doctype html><meta charset=utf-8><title>frq</title>"
52- "<body style=\"font:15px system-ui;background:#242424;color:#fff;padding:40px\">"
53- "<p id=m>Finishing sign-in…</p>"
54- (when return-url
55- (str "<p><a id=b href=\"" return-url "\" hidden "
56- "style=\"display:inline-block;padding:12px 20px;border-radius:8px;"
57- "background:#5a7fd0;color:#fff;text-decoration:none\">Return to frq</a></p>"))
58- "<script>"
59- "var h=location.hash.replace(/^#/,'');"
60- "var p=new URLSearchParams(h).get('oauth')||h.replace(/^oauth=/,'');"
61- "if(!p){document.getElementById('m').textContent='No sign-in payload in this URL.';}"
62- "else{fetch('/capture',{method:'POST',body:p})"
63- ".then(function(){document.getElementById('m').textContent="
64- (if return-url "'Signed in — returning to frq…';" "'Signed in — you can close this tab.';")
65- (when return-url
66- (str "var b=document.getElementById('b');b.hidden=false;"
67- ;; Chrome answers a scripted navigation to a scheme of its own
68- ;; only sometimes; the link is there for when it does not.
69- "location.href=b.href;"))
70- "})"
71- ".catch(function(e){document.getElementById('m').textContent='Handoff failed: '+e;});}"
72- "</script></body>"))
73 45
74 (defn- respond! [fd body content-type]46 (defn- respond! [fd body content-type]
75 (let [head (str "HTTP/1.1 200 OK\r\nContent-Type: " content-type47 (let [head (str "HTTP/1.1 200 OK\r\nContent-Type: " content-type