source_loginRouter.bs

import "pkg:/source/api/ApiClient.bs"

' ===========================================================================
' Pre-login coordinator
'
' The pre-login screens (SetServerScreen / UserSelect / LoginScene) are self-contained
' ROUTED views: they own their UI and emit a high-level INTENT
' (m.scene.preLoginIntent = "<action>") with the payload on their own top fields. main.bs
' observes preLoginIntent on the main thread and dispatches here.
'
' This coordinator is MAIN-THREAD code (it shares Main()'s `m`, the established
' source/ pattern). The bootstrap API calls it makes (server connect, AboutMe,
' getToken, GetPublicUsers) are SYNCHRONOUS and block the main thread — which is
' permitted (sgRouter runs on the render thread, which may not block). Each call is
' isolated behind one step so a future sync->async swap is mechanical.
'
' It drives navigation by callFunc'ing JRScene's render-thread routerNavigate; it
' never touches the sgrouter namespace directly (that resolves on the render thread).
' ===========================================================================

' Cold-start (and session-reset) fast path. Runs the saved-server resolution + saved-
' token validation WITHOUT showing any interactive UI, and returns a decision telling
' main.bs which route to bring the router up on (or that we are already logged in).
' Mirrors LoginFlow()'s startLogin: server block + the saved-active-user branch.
function beginLogin() as object
  ' One ledger run per pre-login resolution. See the note above `enterDecision` for what
  ' `paint` means for a coordinator that is not a screen, and why the FILLS rather than the
  ' paint/settle split are what this run is read for.
  screenLoad.begin("preLogin")

  ' === Server resolution (was LoginFlow startLogin:) ===
  serverUrl = getSetting("server")
  startOver = true
  if isValid(serverUrl)
    print "Previous server connection saved to registry"
    ' Pass originalUrl to preserve the user's input for re-discovery on each connection.
    '
    ' Declared inside the branch rather than around it: with no saved server nothing is
    ' fetched at all, and a fill recorded for work that never ran is not a 0 ms fill — it
    ' is a fill that did not happen. The `else` below is that path.
    screenLoad.pending("serverConnect")
    startOver = not server.UpdateURL(serverUrl, serverUrl)
    screenLoad.resolve("serverConnect")
    if startOver then print "Could not connect to previously saved server."
  else
    print "No previous server connection saved to registry"
  end if

  invalidServer = true
  if not startOver
    m.scene.isLoading = true
    ' A SECOND fill rather than folding this into `serverConnect`, because reading the code
    ' says these are two round trips to the SAME endpoint: `UpdateURL` calls `Populate`,
    ' which calls `ServerInfo()`, and then this line calls `ServerInfo()` again.
    '
    ' Splitting them keeps this one's cost from being attributed to the connect, and names
    ' it correctly on the day it IS the slowest fill (a WAN server, where round trips stop
    ' being ~30 ms). It does NOT by itself confirm the duplicate: the ledger publishes only
    ' the SLOWEST fill, so on a LAN both sit far under `userLoad` and never surface. The
    ' bootstrap's sync calls emit no `[http]` line either, so settling whether that second
    ' round trip is real needs a console probe, not this record.
    screenLoad.pending("serverInfo")
    invalidServer = ServerInfo().Error
    screenLoad.resolve("serverInfo")
    m.scene.isLoading = false
  end if

  if startOver or invalidServer
    ' Need to pick a server interactively.
    return { status: "server" }
  end if

  ' Server is valid — resolve the user.
  return resolveUser()
end function

' Validate a saved auth token: set it on the local user and verify it with AboutMe().
' On success, logs the user in (user.Login) and returns true. On failure returns false —
' the caller owns the cleanup/fallback, which diverges between the two call sites (cold
' start unsets active_user + recurses; the user picker falls through to a no-password login).
' Shared by resolveUser (cold start) and onUserSelected (user picker).
function validateSavedToken(localUser as object, token as string) as boolean
  localUser.authToken = token
  print "Attempting to use API with auth token"
  ' TWO fills, because one number here was actively misleading. Measured on `.177`, the
  ' whole function took 407–461 ms and the round trip was 29–47 ms of it: `user.Login`
  ' below loads the user's settings out of the registry, on the main thread, and this
  ' function does not return until it finishes. A single `session` fill therefore read as
  ' "the server took 460 ms" when the server took 40.
  '
  ' The fills no-op when no ledger is open, which is the `onUserSelected` path — the user
  ' picker calls this too, and that load is not one `beginLogin` opened.
  screenLoad.pending("session")
  currentUser = AboutMe()
  screenLoad.resolve("session")
  if isValid(currentUser)
    print "Success! Auth token is still valid"
    screenLoad.pending("userLoad")
    user.Login(currentUser, true)
    screenLoad.resolve("userLoad")
    return true
  end if
  return false
end function

' Decide the user step given a valid server. Returns one of:
'   { status: "success" }                — already authenticated (fast path)
'   { status: "users", users: [...] }    — show the user picker
'   { status: "login", username: "" }    — go straight to manual sign-in
' enterDecision maps each status to its route; the decision carries only payload.
' Mirrors LoginFlow()'s user section (active-user branch + no-active-user branch).
function resolveUser() as object
  localUser = m.global.user
  activeUser = getSetting("active_user")

  if not isValid(activeUser)
    ' No active user — build the public + saved user list and decide.
    print "No active user found in registry"
    ' The user picker's whole network wait. It happens HERE, before /users is routed to, so
    ' `UserSelect` mounts with its list already in hand — the reason that screen carries no
    ' content fill of its own.
    screenLoad.pending("users")
    users = buildPublicUserList()
    screenLoad.resolve("users")
    m.loginCtx = { hasPublicUsers: users.count() > 0 }
    if users.count() > 0
      return { status: "users", users: users }
    end if
    ' No users to pick — go straight to manual sign-in.
    return { status: "login", username: "" }
  end if

  ' Active user present — try the saved token, then a no-password login. All sync.
  print "Active user found in registry"
  localUser.id = activeUser
  myUsername = getUserSetting("username")
  myAuthToken = getUserSetting("authToken")
  myPrimaryImageTag = getUserSetting("primaryImageTag")

  if isValid(myAuthToken) and isValid(myUsername)
    print "Auth token found in registry"
    localUser.name = myUsername
    if isValidAndNotEmpty(myPrimaryImageTag) then localUser.primaryImageTag = myPrimaryImageTag

    ' `validateSavedToken` declares its own `session` + `userLoad` fills — the split lives
    ' there because that is where the two halves are, and because the user-picker path
    ' calls the same function.
    if not validateSavedToken(localUser, myAuthToken)
      print "Auth token is no longer valid - attempting no-password login"
      screenLoad.pending("noPasswordLogin")
      userData = getToken(myUsername, "")
      screenLoad.resolve("noPasswordLogin")
      if isValid(userData)
        print "login success!"
        user.Login(userData, true)
        return { status: "success" }
      end if
      print "Auth failed. Deleting token and restarting user resolution"
      unsetUserSetting("authToken")
      unsetUserSetting("username")
      if isValid(myPrimaryImageTag) then unsetUserSetting("primaryImageTag")
      unsetSetting("active_user")
      user.Logout()
      ' active_user is now unset → recursion lands in the user-picker branch (no re-loop).
      return resolveUser()
    end if
  else
    print "No auth token found in registry"
  end if

  ' Final guard (was LoginFlow 223-228): if we still have no usable session, restart.
  if not isValid(localUser.id) or not isValid(localUser.authToken)
    print "Login incomplete, restarting user resolution"
    unsetSetting("active_user")
    user.Logout()
    return resolveUser()
  end if

  return { status: "success" }
end function

' Build the public + saved user list for the picker. Public users from the server,
' plus saved users for this server id not already in the public list. Mirrors
' LoginFlow 60-104. Returns an array of PublicUserData nodes.
function buildPublicUserList() as object
  publicUsers = GetPublicUsers()
  numPubUsers = 0
  if isValid(publicUsers) then numPubUsers = publicUsers.count()

  savedUsers = getSavedUsers()
  numSavedUsers = savedUsers.count()

  publicUsersNodes = []
  publicUserIds = []

  if numPubUsers > 0
    for each item in publicUsers
      userData = CreateObject("roSGNode", "PublicUserData")
      userData.id = item.Id
      userData.name = item.Name
      if isValidAndNotEmpty(item.PrimaryImageTag)
        userData.ImageURL = UserImageURL(userData.id, { "tag": item.PrimaryImageTag })
      end if
      publicUsersNodes.push(userData)
      publicUserIds.push(userData.id)
    end for
  end if

  if numSavedUsers > 0
    for each savedUser in savedUsers
      if serverIdsMatch(savedUser.serverId, m.global.server.id)
        ' only show unique userids on screen
        if not arrayHasValue(publicUserIds, savedUser.Id)
          userData = CreateObject("roSGNode", "PublicUserData")
          userData.id = savedUser.Id
          if isValid(savedUser.username) then userData.name = savedUser.username
          if isValidAndNotEmpty(savedUser.primaryImageTag)
            userData.ImageURL = UserImageURL(userData.id, { "tag": savedUser.primaryImageTag })
          end if
          publicUsersNodes.push(userData)
        end if
      end if
    end for
  end if

  return publicUsersNodes
end function

' Enter (or re-enter) the login flow. Called at cold start AND on every session reset
' (Change Server / User / Sign Out). Re-resolves the pre-login locale first — without
' this, a changed global sign-in language would only apply after a full app restart, and
' the sign-in screens would render in the just-signed-out user's language rather than the
' device-wide default (isPostLogin defaults false → pre-login cascade).
sub reenterLogin()
  loadTranslations(resolveTranslationLocale())
  enterDecision(beginLogin())
end sub

' Bring the router up on (or navigate it to) the route the decision names. Shared by
' cold start, session reset, and the post-server-connect step.
' clearSpinner: pass true when navigating here with a blocking spinner up, so it stays up across the
' async nav and clears at NavigationEnd instead of being stopped early (which re-shows the outgoing
' view for a frame). The success branch defers to finishLogin, which carries the spinner to Home.
'
' ## What `paint` means for `preLogin`, which is NOT a screen
'
' Everywhere else in this app `screenLoad.paint` is a screen that rendered something the user
' can act on. Here it is the coordinator handing a route to the router: the user is on a
' spinner for the whole run and CANNOT act at this moment — the destination view has not
' mounted yet (sgRouter resolves at NavigationEnd, on the render thread, where this ledger's
' `m` does not reach). The destination's own paint is a SEPARATE run, the same shape `search`
' established for a screen whose two loads are separated by a user.
'
' So this run's paint/settle split says nothing: every fill is a synchronous main-thread call
' that has already resolved by the time we get here, so `settled` lands on the same
' millisecond as `paint` — the `query` variant of `search` all over again. **Read the fills.**
' They are the actionable decomposition: which of the blocking round trips cost what.
'
' `variant` names WHICH load, not which destination: `start` is a cold start or a session
' reset arriving through `beginLogin`, `connect` is a server submitted from SetServerScreen.
' The destination is already legible from the fills (a run carrying `users` built the picker),
' and one run per entry point is what keeps two loads that route to the same place from
' merging into one population.
sub enterDecision(decision as object, clearSpinner = false as boolean, variant = "start" as string)
  screenLoad.paint(variant)

  if decision.status = "success"
    finishLogin()
  else if decision.status = "server"
    routerNav("/server", {}, clearSpinner)
  else if decision.status = "users"
    routerNav("/users", { users: decision.users }, clearSpinner)
  else if decision.status = "login"
    routerNav("/login", { username: decision.username }, clearSpinner)
  end if
end sub

' ---------------------------------------------------------------------------
' Intent dispatch — called from main.bs's event loop when a pre-login view sets
' m.scene.preLoginIntent. The payload is read off the active routed view.
' ---------------------------------------------------------------------------
sub handlePreLoginIntent(action as string)
  if action = "serverSubmitted"
    onServerSubmitted()
  else if action = "userSelected"
    onUserSelected()
  else if action = "manualLogin"
    routerNav("/login", { username: "" })
  else if action = "userBack"
    onUserBack()
  else if action = "quickConnectAuthenticated"
    onQuickConnectAuthenticated()
  else if action = "credentialsSubmitted"
    onCredentialsSubmitted()
  else if action = "loginBack"
    onLoginBack()
  end if
end sub

' Server URL submitted from SetServerScreen. Mirrors CreateServerGroup's submit handler.
sub onServerSubmitted()
  view = getActiveView()
  if not isValid(view) then return
  originalUrl = view.enteredUrl
  ' enteredUrl reads invalid when the active view isn't SetServerScreen — a stale preLoginIntent that
  ' drained after activeRoutedView advanced (e.g. a duplicate submit landing post-navigation). Ignore
  ' it: passing invalid to inferServerUrl(as string) crashes, and coercing to "" would run a bogus
  ' connect whose failure path signs the user out.
  if not isValid(originalUrl) then return

  ' previousServerUrl: the canonical URL before this connect attempt, used to detect a
  ' server change and reset stale username/password (CreateServerGroup line 333/398).
  previousServerUrl = m.global.server.serverUrl

  ' The connect is this component's SECOND load, and a person types in between — so it is its
  ' own run, told apart by `variant`, exactly as `search` separates its open from its query.
  screenLoad.begin("preLogin")

  ' Disable the remote across the blocking connect (inferServerUrl waits up to 15s, ServerInfo() is
  ' sync) so repeat Submit presses can't queue duplicate serverSubmitted intents. Matches onUserSelected.
  startLoadingSpinner()
  ' `infer` is its own fill because it is the one step here that can dominate everything else:
  ' it probes for a scheme and waits up to 15 s, against two round trips measured in tens of ms
  ' on a LAN. Folded into the connect it would be an outlier nobody could attribute.
  screenLoad.pending("infer")
  serverUrl = inferServerUrl(originalUrl)
  screenLoad.resolve("infer")
  screenLoad.pending("serverConnect")
  isConnected = server.UpdateURL(serverUrl, originalUrl)
  screenLoad.resolve("serverConnect")
  serverInfoResult = invalid
  if isConnected
    screenLoad.pending("serverInfo")
    serverInfoResult = ServerInfo()
    screenLoad.resolve("serverInfo")
    canonicalUrl = m.global.server.serverUrl
    if previousServerUrl <> canonicalUrl
      setSetting("username", "")
      setSetting("password", "")
    end if
  end if

  if isConnected = false or not isValid(serverInfoResult) or (isValid(serverInfoResult.Error) and serverInfoResult.Error)
    ' Failure stays here — stop the spinner so SetServerScreen re-shows with the error.
    ' Deliberately NOT painted: a connect that failed is not a load, so the run stays open and
    ' emits nothing rather than publishing a time for something that did not happen. The next
    ' attempt's `begin` restarts it.
    stopLoadingSpinner()
    print "Server not found, is it online?"
    view.errorMessage = translate(translationKeys.MessageServerNotFoundIsItOnline)
    SignOut(false)
    return
  end if

  ' Connected — persist, resolve the user step, navigate. Keep the spinner up across the async nav
  ' (clearSpinner=true, cleared at NavigationEnd) so the hidden SetServerScreen isn't re-shown for a
  ' frame before the destination mounts.
  SaveServerList()
  enterDecision(resolveUser(), true, "connect")
end sub

' A public/saved user was picked in UserSelect. Mirrors LoginFlow 120-163.
sub onUserSelected()
  view = getActiveView()
  if not isValid(view) then return
  userSelected = view.selectedUserName
  userId = view.selectedUserId
  if not isValidAndNotEmpty(userSelected) then return

  startLoadingSpinner()
  localUser = m.global.user
  localUser.name = userSelected
  localUser.id = userId

  ' Try a saved auth token for this user.
  myToken = getUserSetting("authToken")
  if isValid(myToken)
    print "Auth token found in registry for selected user"
    if validateSavedToken(localUser, myToken)
      finishLogin()
      return
    end if
    print "Auth token is no longer valid - deleting token"
    unsetUserSetting("authToken")
    unsetUserSetting("username")
    unsetUserSetting("primaryImageTag")
  else
    print "No auth token found in registry for selected user"
  end if

  ' Try a no-password login.
  print "Attempting to login with no password"
  userData = getToken(userSelected, "")
  if isValid(userData)
    print "login success!"
    user.Login(userData, true)
    finishLogin()
    return
  end if

  ' Password required — go to manual sign-in with the username prefilled. Keep the blocking spinner
  ' up across the async nav and clear it when LoginScene mounts (clearSpinner=true). Stopping it
  ' here first re-shows the UserSelect row for a frame before LoginScene mounts — the same #677
  ' flash class as the login -> Home path.
  print "Auth failed. Password required"
  routerNav("/login", { username: userSelected }, true)
end sub

' Back from UserSelect → change server. Mirrors LoginFlow 114-119.
sub onUserBack()
  server.Delete()
  unsetSetting("server")
  routerNav("/server", {})
end sub

' Quick Connect produced a session. UserSelect owns everything before this point
' — it initiates, polls for approval, exchanges the secret and asks whether to
' save credentials, all as render-thread promises — and hands over the finished
' AuthenticationResult. What is left is the one step that must NOT run on the
' render thread: user.Login() reads and writes the registry.
'
' Deliberately the same two lines the password path ends on, because it IS the
' same thing: an AuthenticationResult carrying an AccessToken and a UserDto. The
' old Quick Connect ran its own login on a TASK thread, then re-fetched the user
' with AboutMe() and re-loaded preferences that user.Login() had already loaded —
' three round trips where the password path makes one.
sub onQuickConnectAuthenticated()
  view = getActiveView()
  if not isValid(view) then return

  auth = view.quickConnectAuth
  saveCreds = view.saveCredentials
  ' Stale-intent guard (as in onCredentialsSubmitted): if the active view is no
  ' longer UserSelect when this drains, the field reads invalid. Ignore it.
  if not isValid(auth) then return

  startLoadingSpinner()
  activeUser = userDataFromAuthResult(auth)
  if not isValid(activeUser)
    stopLoadingSpinner()
    print "Quick Connect returned no usable session"
    return
  end if

  user.Login(activeUser, saveCreds)
  ' user.Login persists authToken + primaryImageTag when saveCredentials is set,
  ' but not the username — the same gap onCredentialsSubmitted fills by hand, and
  ' the saved-user picker reads it (UserData.loadFromRegistry).
  if saveCreds then setUserSetting("username", activeUser.username)
  finishLogin()
end sub

' Credentials submitted from LoginScene. Mirrors CreateSigninGroup's submit handler.
sub onCredentialsSubmitted()
  view = getActiveView()
  if not isValid(view) then return
  username = view.enteredUsername
  password = view.enteredPassword
  saveCreds = view.saveCredentials
  ' Stale-intent guard (as in onServerSubmitted): if the active view isn't LoginScene when this drains,
  ' these fields read invalid and getToken(as string) would crash. Ignore the superseded intent.
  if not isValid(username) or not isValid(password) then return

  startLoadingSpinner()
  activeUser = getToken(username, password)
  if isValid(activeUser)
    if saveCreds = true
      user.Login(activeUser, true)
      setUserSetting("authToken", activeUser.token)
      setUserSetting("username", username)
      if isValidAndNotEmpty(activeUser.json.PrimaryImageTag)
        setUserSetting("primaryImageTag", activeUser.json.PrimaryImageTag)
      end if
    else
      user.Login(activeUser)
    end if
    finishLogin()
    return
  end if

  stopLoadingSpinner()
  print "Login attempt failed..."
  view.alert = translate(translationKeys.ErrorLoginAttemptFailed)
end sub

' Back from LoginScene. Mirrors CreateSigninGroup back (showScenes 170-178): return to
' the user picker if there were public users, otherwise drop back to server select.
sub onLoginBack()
  hasUsers = isValid(m.loginCtx) and m.loginCtx.hasPublicUsers = true
  if hasUsers
    routerNav("/users", { users: buildPublicUserList() })
  else
    server.Delete()
    unsetSetting("server")
    routerNav("/server", {})
  end if
end sub

' Login complete — run the post-login bootstrap (per-user font processing) and bring up
' Home. loadHomeScreen()/createAndShowHomeGroup() (main.bs) navigate the router to "/",
' deferring until font download completes when UI fallback fonts are enabled.
' The single transition into the app. EVERY login path converges here — password,
' no-password, saved-token and Quick Connect — either directly or through
' enterDecision's `status = "success"` branch, which is where the two bootstrap
' paths (validateSavedToken, the no-password retry in resolveUser) land.
'
' That convergence is why the authentication check belongs HERE and nowhere else.
' `user.Login()` is a `sub`: it silently returns when the payload carries no
' usable id (session.bs `hasValidId`), and having no return value it cannot say
' so. Before this guard, all five call sites answered that by re-implementing a
' WEAKER check of their own — `isValid(<node>)` alone — and then booting the app
' regardless. A payload that satisfied the caller but not Login left
' `m.global.user` holding its setGlobals() defaults and put the user on Home with
' nobody signed in: no message, no recovery, and every request going out with an
' empty `Token=`.
'
' One guard at the one choke point, rather than five copies of Login's own
' precondition — so a sixth login path inherits it instead of having to remember
' it. See user.IsAuthenticated() for why this reads `isLoaded` rather than the
' `id`/`authToken` rule JellyfinUser.xml states (those are written speculatively
' before auth, so they answer "yes" after a failed attempt).
sub finishLogin()
  if not user.IsAuthenticated()
    ' Not reachable from a healthy server — it needs a 200 whose AccessToken is
    ' present but whose User carries no usable Id — so this is the honest report
    ' of a should-not-happen, not a routine branch. It still has to be a report:
    ' silently returning here leaves the blocking login spinner up forever.
    print "Login did not establish a session - returning to the picker"
    stopLoadingSpinner()
    ' Clears the SPECULATIVE id/authToken this attempt left on the node. Without
    ' it the next screen still reads a half-populated user, which is the state
    ' this whole guard exists to stop anyone acting on. Purely local — Logout
    ' resets the node and the theme; it makes no request.
    user.Logout()
    ' A toast, NOT showAlertDialog: this branch navigates immediately, and an
    ' overlay dialog is appended to the SCENE, so it would outlive the navigation
    ' and land on top of the incoming screen while contending with it for focus.
    ' Toasts take no focus. Same main-thread reporting replayRoute.bs uses.
    displayToast(translate(translationKeys.ErrorLoginAttemptFailed), "error")
    ' Deliberately NOT onLoginBack(), though it makes the same picker-vs-fallback
    ' choice: its fallback branch runs `server.Delete()` + `unsetSetting("server")`.
    ' That is right for the case it was written for — the user pressed Back from
    ' sign-in because they want a DIFFERENT server — and wrong here, where nobody
    ' asked to forget anything. On a private server with no public users it would
    ' destroy a working server config over a failed sign-in.
    '
    ' So: the picker when this server has public users to pick from, and manual
    ' sign-in when it does not, which is the same fallback onUserSelected reaches
    ' for when a no-password login is refused. The server is left alone either way.
    if isValid(m.loginCtx) and m.loginCtx.hasPublicUsers = true
      routerNav("/users", { users: buildPublicUserList() })
    else
      routerNav("/login", { username: "" })
    end if
    return
  end if

  initializeFallbackFont()
  loadHomeScreen()
end sub

' Thin main-thread → render-thread navigation bridge. clearSpinner (login paths only) keeps a
' blocking login spinner up across the async nav and clears it when the destination view mounts —
' see JRScene.navigateThenFocus.
sub routerNav(routePath as string, context as object, clearSpinner = false as boolean)
  m.scene.callFunc("routerNavigate", routePath, context, clearSpinner)
end sub