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>
939d2ac parent: c6506f2 modified
common/frq/io.cljc +10 -0 | @@ -104,6 +104,16 @@ | ||
| 104 | 104 | |
| 105 | 105 | ;; ------------------------------------------------------------------- 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 | 117 | (defn wall-nanos [] (call :wall-nanos [])) |
| 108 | 118 | (defn mono-nanos [] (call :mono-nanos [])) |
| 109 | 119 | |
| @@ -104,6 +104,16 @@ | |||
| 104 | 104 | ||
| 105 | ;; ------------------------------------------------------------------- time | 105 | ;; ------------------------------------------------------------------- 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 @@ | ||
| 94 | 94 | :nick (atproto/json-str body "nick") |
| 95 | 95 | :did (atproto/json-str body "did") |
| 96 | 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>")) | |
| @@ -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 @@ | ||
| 10 | 10 | <activity |
| 11 | 11 | android:name=".MainActivity" |
| 12 | 12 | android:exported="true" |
| 13 | - android:launchMode="singleTop" | |
| 13 | + android:launchMode="singleTask" | |
| 14 | 14 | android:taskAffinity="" |
| 15 | 15 | android:theme="@style/LaunchTheme" |
| 16 | 16 | android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode" |
| @@ -28,6 +28,23 @@ | ||
| 28 | 28 | <action android:name="android.intent.action.MAIN"/> |
| 29 | 29 | <category android:name="android.intent.category.LAUNCHER"/> |
| 30 | 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 | 48 | </activity> |
| 32 | 49 | <!-- Don't delete the meta-data below. |
| 33 | 50 | This is used by the Flutter tool to generate GeneratedPluginRegistrant.java --> |
| @@ -41,6 +58,13 @@ | ||
| 41 | 58 | |
| 42 | 59 | In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. --> |
| 43 | 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 | 68 | <intent> |
| 45 | 69 | <action android:name="android.intent.action.PROCESS_TEXT"/> |
| 46 | 70 | <data android:mimeType="text/plain"/> |
| @@ -10,7 +10,7 @@ | |||
| 10 | <activity | 10 | <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 @@ | ||
| 6 | 6 | |
| 7 | 7 | #include "generated_plugin_registrant.h" |
| 8 | 8 | |
| 9 | +#include <url_launcher_linux/url_launcher_plugin.h> | |
| 9 | 10 | |
| 10 | 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 | } |
| @@ -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 @@ | ||
| 3 | 3 | # |
| 4 | 4 | |
| 5 | 5 | list(APPEND FLUTTER_PLUGIN_LIST |
| 6 | + url_launcher_linux | |
| 6 | 7 | ) |
| 7 | 8 | |
| 8 | 9 | list(APPEND FLUTTER_FFI_PLUGIN_LIST |
| @@ -3,6 +3,7 @@ | |||
| 3 | # | 3 | # |
| 4 | 4 | ||
| 5 | list(APPEND FLUTTER_PLUGIN_LIST | 5 | list(APPEND FLUTTER_PLUGIN_LIST |
| 6 | + url_launcher_linux | ||
| 6 | ) | 7 | ) |
| 7 | 8 | ||
| 8 | list(APPEND FLUTTER_FFI_PLUGIN_LIST | 9 | list(APPEND FLUTTER_FFI_PLUGIN_LIST |
modified
flutter/pubspec.lock +78 -1 | @@ -139,6 +139,11 @@ packages: | ||
| 139 | 139 | description: flutter |
| 140 | 140 | source: sdk |
| 141 | 141 | version: "0.0.0" |
| 142 | + flutter_web_plugins: | |
| 143 | + dependency: transitive | |
| 144 | + description: flutter | |
| 145 | + source: sdk | |
| 146 | + version: "0.0.0" | |
| 142 | 147 | hooks: |
| 143 | 148 | dependency: transitive |
| 144 | 149 | description: |
| @@ -400,6 +405,70 @@ packages: | ||
| 400 | 405 | url: "https://pub.dev" |
| 401 | 406 | source: hosted |
| 402 | 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 | 472 | vector_math: |
| 404 | 473 | dependency: transitive |
| 405 | 474 | description: |
| @@ -416,6 +485,14 @@ packages: | ||
| 416 | 485 | url: "https://pub.dev" |
| 417 | 486 | source: hosted |
| 418 | 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 | 496 | xdg_directories: |
| 420 | 497 | dependency: transitive |
| 421 | 498 | description: |
| @@ -434,4 +511,4 @@ packages: | ||
| 434 | 511 | version: "3.1.4" |
| 435 | 512 | sdks: |
| 436 | 513 | dart: ">=3.13.0 <4.0.0" |
| 437 | - flutter: ">=3.38.4" | |
| 514 | + flutter: ">=3.44.0" | |
| @@ -139,6 +139,11 @@ packages: | |||
| 139 | description: flutter | 139 | description: flutter |
| 140 | source: sdk | 140 | 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: transitive | 148 | dependency: transitive |
| 144 | description: | 149 | description: |
| @@ -400,6 +405,70 @@ packages: | |||
| 400 | url: "https://pub.dev" | 405 | url: "https://pub.dev" |
| 401 | source: hosted | 406 | 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: transitive | 473 | dependency: transitive |
| 405 | description: | 474 | description: |
| @@ -416,6 +485,14 @@ packages: | |||
| 416 | url: "https://pub.dev" | 485 | url: "https://pub.dev" |
| 417 | source: hosted | 486 | 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: transitive | 497 | 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: | ||
| 37 | 37 | path_provider: ^2.1.6 |
| 38 | 38 | ed25519_edwards: ^0.3.2 |
| 39 | 39 | 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 | 44 | dev_dependencies: |
| 41 | 45 | flutter_test: |
| 42 | 46 | sdk: flutter |
| @@ -37,6 +37,10 @@ dependencies: | |||
| 37 | path_provider: ^2.1.6 | 37 | path_provider: ^2.1.6 |
| 38 | ed25519_edwards: ^0.3.2 | 38 | ed25519_edwards: ^0.3.2 |
| 39 | crypto: ^3.0.7 | 39 | 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: flutter | 46 | sdk: flutter |
modified
flutter/src/frq/hiccup.cljd +19 -24 | @@ -143,28 +143,14 @@ | ||
| 143 | 143 | [node] |
| 144 | 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 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. | |
| 153 | 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 | 154 | (defn- end-ward! |
| 169 | 155 | "Put `k` at the end after this frame, when it should be. |
| 170 | 156 | |
| @@ -565,11 +551,20 @@ | ||
| 565 | 551 | ;; "competing" and then draws nothing. |
| 566 | 552 | :scroll |
| 567 | 553 | (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))))) | |
| 573 | 568 | |
| 574 | 569 | ;; A tag this backend has not grown yet still shows its children — which |
| 575 | 570 | ;; 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 a | 148 | +;; 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 every | 149 | +;; 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 each | 150 | +;; 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 | :scroll | 552 | :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/SingleChildScrollView | 556 | + ;; `:managed`, so the controller belongs to this widget and is |
| 571 | - .controller ctrl | 557 | + ;; 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 — which | 569 | ;; 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 so | 570 | ;; 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 @@ | ||
| 17 | 17 | shape it should take rather than as code known to compile." |
| 18 | 18 | (:require ["dart:convert" :as conv] |
| 19 | 19 | ["dart:io" :as io] |
| 20 | + ["package:url_launcher/url_launcher.dart" :as launcher] | |
| 20 | 21 | [frq.io :as fio])) |
| 21 | 22 | |
| 22 | 23 | (defonce ^:private uptime |
| @@ -74,6 +75,15 @@ | ||
| 74 | 75 | [dir] |
| 75 | 76 | (fio/install! |
| 76 | 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 | 87 | :config-dir (fn [] dir) |
| 78 | 88 | :file-exists? (fn [p] (.existsSync (io/File. p))) |
| 79 | 89 | :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 uptime | 23 | (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 @@ | ||
| 23 | 23 | [frq.hiccup :as h] |
| 24 | 24 | [frq.theme :as t] |
| 25 | 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 | 29 | [frq.crypto.dart :as crypto-dart] |
| 27 | 30 | [frq.net.dart :as net] |
| 28 | 31 | [frq.atproto.dart :as atproto] |
| @@ -38,6 +41,7 @@ | ||
| 38 | 41 | [frq.rooms :as rooms] |
| 39 | 42 | [frq.members :as members] |
| 40 | 43 | [frq.oauth.core :as oauth] |
| 44 | + [frq.oauth.dart :as oauth-dart] | |
| 41 | 45 | [frq.irc.parse :as irc] |
| 42 | 46 | [frq.irc.handshake :as handshake])) |
| 43 | 47 | |
| @@ -196,10 +200,22 @@ | ||
| 196 | 200 | ;; it does. |
| 197 | 201 | (store/save-session! tokens) |
| 198 | 202 | (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)))) | |
| 203 | 219 | |
| 204 | 220 | (defn ^:async sign-in! |
| 205 | 221 | "Fill `cells/session` for the mode that was chosen, or say why not. |
| @@ -219,6 +235,20 @@ | ||
| 219 | 235 | (do (reset! cells/status "Signing in…") |
| 220 | 236 | (atproto/create-session @cells/form-handle |
| 221 | 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 | 252 | true |
| 223 | 253 | (catch Object e |
| 224 | 254 | (reset! cells/session nil) |
| @@ -417,6 +447,17 @@ | ||
| 417 | 447 | ;; Before any widget is built: a cell that changes before its watch is on |
| 418 | 448 | ;; is a change the screen never hears about. |
| 419 | 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 | 461 | ;; What the shared screen calls. The desktop installs frq.state's |
| 421 | 462 | ;; reducers here; this installs the phone's. |
| 422 | 463 | (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-handle | 236 | (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 | true | 252 | true |
| 223 | (catch Object e | 253 | (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 on | 447 | ;; 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's | 461 | ;; 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 @@ | ||
| 6 | 6 | backend. Every desktop entry point requires this before `frq.app`." |
| 7 | 7 | (:require [clojure.string :as str] |
| 8 | 8 | [frq.io :as io] |
| 9 | + [frq.platform :as platform] | |
| 9 | 10 | [jolt.host :as host])) |
| 10 | 11 | |
| 11 | 12 | (def ^:private zone |
| @@ -64,6 +65,7 @@ | ||
| 64 | 65 | |
| 65 | 66 | (io/install! |
| 66 | 67 | {:getenv host/getenv |
| 68 | + :open-url! platform/open-url! | |
| 67 | 69 | :config-dir config-dir |
| 68 | 70 | :file-exists? host/file-exists? |
| 69 | 71 | :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 zone | 12 | (def ^:private zone |
| @@ -64,6 +65,7 @@ | |||
| 64 | 65 | ||
| 65 | (io/install! | 66 | (io/install! |
| 66 | {:getenv host/getenv | 67 | {:getenv host/getenv |
| 68 | + :open-url! platform/open-url! | ||
| 67 | :config-dir config-dir | 69 | :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 @@ | ||
| 38 | 38 | |
| 39 | 39 | ;; ------------------------------------------------------------------ capture |
| 40 | 40 | |
| 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) | |
| 73 | 45 | |
| 74 | 46 | (defn- respond! [fd body content-type] |
| 75 | 47 | (let [head (str "HTTP/1.1 200 OK\r\nContent-Type: " content-type |
| @@ -38,38 +38,10 @@ | |||
| 38 | 38 | ||
| 39 | ;; ------------------------------------------------------------------ capture | 39 | ;; ------------------------------------------------------------------ capture |
| 40 | 40 | ||
| 41 | -(defn- capture-html | 41 | +(def capture-html |
| 42 | - "The page the browser lands on with the handoff in its fragment. Its one job | 42 | + "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-type | 47 | (let [head (str "HTTP/1.1 200 OK\r\nContent-Type: " content-type |