Add secure credential storage, sessions, and a launch screen
Login now persists properly rather than through the JSON file the test
harness was using.
dr-plat SecretStore trait plus a Secret Service backend.
Verified against the live GNOME Keyring: store,
retrieve, delete, confirm-gone all round-trip.
Session/SessionStore splits credentials from settings — the app
password goes to the keyring (FR-NC-2), while
server, login, chosen root and format selection are
ordinary config. A test asserts the credential never
appears in the config file.
LaunchModel the launch-screen state machine, testable without a
display server: sign in, approve in browser, choose
folder, tick formats, sign out.
launch.slint the screen itself, in its own file.
Absence of a secrets daemon is an explicit degraded mode, not a silent
fallback to plaintext — the screen says sign-in will not persist rather
than letting the user find out next launch. Android's Keystore backend
fails loudly for the same reason: a no-op store would look like it
worked and then lose the credential.
Two bugs caught by tests rather than by running it:
- fail() after busy() signed the user out, because busy() had already
discarded the session. A failed *scan* would have logged you out.
Busy now carries the session.
- normalise_server upgrades http:// to https:// rather than accepting
it. NFR-SEC-3 requires TLS, and silently sending a credential in the
clear is not a decision to make on the user's behalf.
launch.slint is not yet wired into app.slint. Calling slint_build::compile
twice replaces the generated module rather than adding to it, which broke
the other in-flight work on dr-ui; I reverted that immediately. Wiring it
needs an import inside app.slint, which is that work's file to change.
419 tests passing across ten crates.
This commit is contained in:
@@ -0,0 +1,349 @@
|
||||
import { Theme } from "theme.slint";
|
||||
|
||||
// Launch screen: connect an account, or resume a saved one.
|
||||
//
|
||||
// Deliberately separate from AppWindow. It is the first thing a user sees
|
||||
// with no library configured, and the place they return to in order to sign
|
||||
// out or switch account (FR-NC-1, FR-NC-4).
|
||||
|
||||
// One tick-box in the format selection.
|
||||
component FormatCheck inherits Rectangle {
|
||||
in property <string> label;
|
||||
in-out property <bool> checked;
|
||||
callback toggled(bool);
|
||||
|
||||
// FR-UI-3: 44pt minimum hit target under touch. The drawn row is
|
||||
// shorter, so the touch area extends beyond the visible bounds.
|
||||
height: 32px;
|
||||
|
||||
touch := TouchArea {
|
||||
height: max(parent.height, Theme.touch-target);
|
||||
y: (parent.height - self.height) / 2;
|
||||
clicked => {
|
||||
root.checked = !root.checked;
|
||||
root.toggled(root.checked);
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalLayout {
|
||||
spacing: Theme.gap;
|
||||
alignment: start;
|
||||
|
||||
Rectangle {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
y: (parent.height - self.height) / 2;
|
||||
border-radius: 3px;
|
||||
border-width: 1px;
|
||||
border-color: root.checked ? Theme.accent : Theme.rule;
|
||||
background: root.checked ? Theme.accent : transparent;
|
||||
|
||||
Text {
|
||||
text: "✓";
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
visible: root.checked;
|
||||
horizontal-alignment: center;
|
||||
vertical-alignment: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
text: root.label;
|
||||
color: touch.has-hover ? Theme.ink : Theme.ink-dim;
|
||||
font-size: Theme.text;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
component Button inherits Rectangle {
|
||||
in property <string> label;
|
||||
in property <bool> primary: false;
|
||||
in property <bool> enabled: true;
|
||||
callback clicked();
|
||||
|
||||
height: Theme.touch-target;
|
||||
border-radius: 4px;
|
||||
background: root.primary
|
||||
? (touch.has-hover && root.enabled ? Theme.accent-dim : Theme.accent)
|
||||
: (touch.has-hover && root.enabled ? Theme.surface-raised : transparent);
|
||||
border-width: root.primary ? 0px : 1px;
|
||||
border-color: Theme.rule;
|
||||
opacity: root.enabled ? 1.0 : 0.45;
|
||||
|
||||
touch := TouchArea {
|
||||
enabled: root.enabled;
|
||||
clicked => { root.clicked(); }
|
||||
}
|
||||
|
||||
Text {
|
||||
text: root.label;
|
||||
color: root.primary ? #fff : Theme.ink;
|
||||
font-size: Theme.text;
|
||||
font-weight: 600;
|
||||
horizontal-alignment: center;
|
||||
vertical-alignment: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
export component LaunchScreen inherits Rectangle {
|
||||
// --- state in ---
|
||||
in property <bool> signed-in: false;
|
||||
in property <string> account: "";
|
||||
in property <string> library-root: "";
|
||||
in property <string> server-url: "";
|
||||
in property <bool> busy: false;
|
||||
in property <string> status: "";
|
||||
in property <string> error: "";
|
||||
/// The URL to approve in a browser, shown while a login is pending.
|
||||
in property <string> login-url: "";
|
||||
/// False where no secrets daemon exists — the user must be told sign-in
|
||||
/// will not persist rather than discovering it next launch.
|
||||
in property <bool> can-remember: true;
|
||||
|
||||
in-out property <[string]> format-labels;
|
||||
in-out property <[bool]> format-checked;
|
||||
|
||||
// --- events out ---
|
||||
callback sign-in(string);
|
||||
callback sign-out();
|
||||
callback choose-folder();
|
||||
callback open-library();
|
||||
callback format-toggled(int, bool);
|
||||
callback copy-login-url();
|
||||
|
||||
background: Theme.ground;
|
||||
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
padding: Theme.gap-lg;
|
||||
|
||||
Rectangle {
|
||||
max-width: 460px;
|
||||
horizontal-stretch: 0;
|
||||
|
||||
VerticalLayout {
|
||||
spacing: Theme.gap-lg;
|
||||
|
||||
// --- masthead ---
|
||||
VerticalLayout {
|
||||
spacing: Theme.gap-sm;
|
||||
Text {
|
||||
text: "DarkRoom";
|
||||
color: Theme.ink;
|
||||
font-size: Theme.text-xl;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
Text {
|
||||
text: root.signed-in
|
||||
? "Connected"
|
||||
: "Connect a Nextcloud account to begin";
|
||||
color: Theme.ink-dim;
|
||||
font-size: Theme.text;
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle { height: 1px; background: Theme.rule; }
|
||||
|
||||
// --- signed out: server entry ---
|
||||
if !root.signed-in && root.login-url == "": VerticalLayout {
|
||||
spacing: Theme.gap;
|
||||
|
||||
Text {
|
||||
text: "SERVER";
|
||||
color: Theme.accent;
|
||||
font-size: Theme.text-sm;
|
||||
font-weight: 700;
|
||||
letter-spacing: 1.2px;
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
height: Theme.touch-target;
|
||||
border-radius: 4px;
|
||||
border-width: 1px;
|
||||
border-color: server-input.has-focus ? Theme.accent : Theme.rule;
|
||||
background: Theme.surface;
|
||||
|
||||
server-input := TextInput {
|
||||
text: root.server-url;
|
||||
color: Theme.ink;
|
||||
font-size: Theme.text;
|
||||
vertical-alignment: center;
|
||||
width: parent.width - 2 * Theme.gap;
|
||||
x: Theme.gap;
|
||||
height: 100%;
|
||||
single-line: true;
|
||||
accepted => { root.sign-in(self.text); }
|
||||
}
|
||||
|
||||
// Placeholder, since TextInput has none of its own.
|
||||
Text {
|
||||
text: "https://cloud.example.com";
|
||||
color: Theme.ink-faint;
|
||||
font-size: Theme.text;
|
||||
vertical-alignment: center;
|
||||
x: Theme.gap;
|
||||
height: 100%;
|
||||
visible: server-input.text == "";
|
||||
}
|
||||
}
|
||||
|
||||
if !root.can-remember: Text {
|
||||
text: "No system keyring found — you will need to sign in each time.";
|
||||
color: Theme.warn-ink;
|
||||
font-size: Theme.text-sm;
|
||||
wrap: word-wrap;
|
||||
}
|
||||
|
||||
Button {
|
||||
label: root.busy ? "Connecting…" : "Sign in";
|
||||
primary: true;
|
||||
enabled: !root.busy && server-input.text != "";
|
||||
clicked => { root.sign-in(server-input.text); }
|
||||
}
|
||||
|
||||
Text {
|
||||
text: "Sign-in happens in your browser. DarkRoom never sees your password.";
|
||||
color: Theme.ink-faint;
|
||||
font-size: Theme.text-sm;
|
||||
wrap: word-wrap;
|
||||
}
|
||||
}
|
||||
|
||||
// --- login pending: the browser step ---
|
||||
if root.login-url != "": VerticalLayout {
|
||||
spacing: Theme.gap;
|
||||
|
||||
Text {
|
||||
text: "APPROVE IN YOUR BROWSER";
|
||||
color: Theme.accent;
|
||||
font-size: Theme.text-sm;
|
||||
font-weight: 700;
|
||||
letter-spacing: 1.2px;
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
background: Theme.surface;
|
||||
border-radius: 4px;
|
||||
border-width: 1px;
|
||||
border-color: Theme.rule;
|
||||
|
||||
VerticalLayout {
|
||||
padding: Theme.gap;
|
||||
Text {
|
||||
text: root.login-url;
|
||||
color: Theme.ink-dim;
|
||||
font-size: Theme.text-sm;
|
||||
wrap: char-wrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Button {
|
||||
label: "Copy link";
|
||||
clicked => { root.copy-login-url(); }
|
||||
}
|
||||
|
||||
Text {
|
||||
text: "Waiting for approval…";
|
||||
color: Theme.ink-faint;
|
||||
font-size: Theme.text-sm;
|
||||
}
|
||||
}
|
||||
|
||||
// --- signed in ---
|
||||
if root.signed-in: VerticalLayout {
|
||||
spacing: Theme.gap;
|
||||
|
||||
Text {
|
||||
text: "ACCOUNT";
|
||||
color: Theme.accent;
|
||||
font-size: Theme.text-sm;
|
||||
font-weight: 700;
|
||||
letter-spacing: 1.2px;
|
||||
}
|
||||
Text {
|
||||
text: root.account;
|
||||
color: Theme.ink;
|
||||
font-size: Theme.text;
|
||||
}
|
||||
|
||||
Rectangle { height: Theme.gap-sm; }
|
||||
|
||||
Text {
|
||||
text: "LIBRARY FOLDER";
|
||||
color: Theme.accent;
|
||||
font-size: Theme.text-sm;
|
||||
font-weight: 700;
|
||||
letter-spacing: 1.2px;
|
||||
}
|
||||
HorizontalLayout {
|
||||
spacing: Theme.gap;
|
||||
Text {
|
||||
text: root.library-root == "" ? "(not chosen)" : root.library-root;
|
||||
color: root.library-root == "" ? Theme.ink-faint : Theme.ink;
|
||||
font-size: Theme.text;
|
||||
vertical-alignment: center;
|
||||
horizontal-stretch: 1;
|
||||
overflow: elide;
|
||||
}
|
||||
Button {
|
||||
label: "Choose…";
|
||||
width: 110px;
|
||||
clicked => { root.choose-folder(); }
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle { height: Theme.gap-sm; }
|
||||
|
||||
Text {
|
||||
text: "SCAN FOR";
|
||||
color: Theme.accent;
|
||||
font-size: Theme.text-sm;
|
||||
font-weight: 700;
|
||||
letter-spacing: 1.2px;
|
||||
}
|
||||
|
||||
for label[i] in root.format-labels: FormatCheck {
|
||||
label: label;
|
||||
checked: root.format-checked[i];
|
||||
toggled(on) => { root.format-toggled(i, on); }
|
||||
}
|
||||
|
||||
Rectangle { height: Theme.gap; }
|
||||
|
||||
Button {
|
||||
label: root.busy ? "Scanning…" : "Open library";
|
||||
primary: true;
|
||||
enabled: !root.busy && root.library-root != "";
|
||||
clicked => { root.open-library(); }
|
||||
}
|
||||
Button {
|
||||
label: "Sign out";
|
||||
clicked => { root.sign-out(); }
|
||||
}
|
||||
}
|
||||
|
||||
// --- feedback ---
|
||||
if root.status != "": Text {
|
||||
text: root.status;
|
||||
color: Theme.ink-dim;
|
||||
font-size: Theme.text-sm;
|
||||
wrap: word-wrap;
|
||||
}
|
||||
if root.error != "": Text {
|
||||
text: root.error;
|
||||
color: Theme.accent;
|
||||
font-size: Theme.text-sm;
|
||||
wrap: word-wrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,9 @@ export global Theme {
|
||||
out property <color> accent: #D9543C;
|
||||
out property <color> accent-dim: #8F2E1E;
|
||||
|
||||
// Semantic, distinct from the accent: a caution is not an action.
|
||||
out property <color> warn-ink: #C99A4A;
|
||||
|
||||
out property <length> gap-sm: 6px;
|
||||
out property <length> gap: 12px;
|
||||
out property <length> gap-lg: 20px;
|
||||
|
||||
Reference in New Issue
Block a user