feat: login MAS OIDC end-to-end (issue 0147)

Backend Go:
- MatrixService with Login/GetSession/Logout bindings
- Uses 3 registry helpers via go.work:
  - mas_oidc_loopback_go_infra (PKCE flow)
  - keyring_token_store_go_infra (SO keychain)
  - matrix_client_init_go_infra (mautrix client)
- whoami helper to discover user_id+device_id pre-init

Frontend React+Vite+TS:
- Mantine v7 + @tabler/icons-react
- LoginScreen with 'Sign in with Matrix' button
- HomeScreen with profile card + logout
- Dark theme violet accent
- @mantine/notifications wired

Wails config:
- Switched to pnpm (workspace: protocol)
- Bindings auto-generated for MatrixService
- 1280x800 default window

Build: 68MB linux/amd64 binary in build/bin/
This commit is contained in:
egutierrez
2026-05-24 23:23:35 +02:00
parent 42adfdfd97
commit f28c2b121e
21 changed files with 2053 additions and 238 deletions
-27
View File
@@ -1,27 +0,0 @@
package main
import (
"context"
"fmt"
)
// App struct
type App struct {
ctx context.Context
}
// NewApp creates a new App application struct
func NewApp() *App {
return &App{}
}
// startup is called when the app starts. The context is saved
// so we can call the runtime methods
func (a *App) startup(ctx context.Context) {
a.ctx = ctx
}
// Greet returns a greeting for the given name
func (a *App) Greet(name string) string {
return fmt.Sprintf("Hello %s, It's show time!", name)
}
+13 -9
View File
@@ -1,7 +1,7 @@
{
"name": "frontend",
"name": "matrix_client_pc-frontend",
"private": true,
"version": "0.0.0",
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
@@ -9,14 +9,18 @@
"preview": "vite preview"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0"
"@mantine/core": "^7.13.0",
"@mantine/hooks": "^7.13.0",
"@mantine/notifications": "^7.13.0",
"@tabler/icons-react": "^3.19.0",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@types/react": "^18.0.17",
"@types/react-dom": "^18.0.6",
"@vitejs/plugin-react": "^2.0.1",
"typescript": "^4.6.4",
"vite": "^3.0.7"
"@types/react": "^18.3.10",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"typescript": "^5.6.2",
"vite": "^5.4.8"
}
}
+1
View File
@@ -0,0 +1 @@
a27c7df4927b39f755bc0add9ca73805
+1445
View File
File diff suppressed because it is too large Load Diff
-59
View File
@@ -1,59 +0,0 @@
#app {
height: 100vh;
text-align: center;
}
#logo {
display: block;
width: 50%;
height: 50%;
margin: auto;
padding: 10% 0 0;
background-position: center;
background-repeat: no-repeat;
background-size: 100% 100%;
background-origin: content-box;
}
.result {
height: 20px;
line-height: 20px;
margin: 1.5rem auto;
}
.input-box .btn {
width: 60px;
height: 30px;
line-height: 30px;
border-radius: 3px;
border: none;
margin: 0 0 0 20px;
padding: 0 8px;
cursor: pointer;
}
.input-box .btn:hover {
background-image: linear-gradient(to top, #cfd9df 0%, #e2ebf0 100%);
color: #333333;
}
.input-box .input {
border: none;
border-radius: 3px;
outline: none;
height: 30px;
line-height: 30px;
padding: 0 10px;
background-color: rgba(240, 240, 240, 1);
-webkit-font-smoothing: antialiased;
}
.input-box .input:hover {
border: none;
background-color: rgba(255, 255, 255, 1);
}
.input-box .input:focus {
border: none;
background-color: rgba(255, 255, 255, 1);
}
+41 -23
View File
@@ -1,28 +1,46 @@
import {useState} from 'react';
import logo from './assets/images/logo-universal.png';
import './App.css';
import {Greet} from "../wailsjs/go/main/App";
import { useEffect, useState } from "react";
import { Box, LoadingOverlay } from "@mantine/core";
import LoginScreen from "./LoginScreen";
import HomeScreen from "./HomeScreen";
import { GetSession } from "../wailsjs/go/main/MatrixService";
function App() {
const [resultText, setResultText] = useState("Please enter your name below 👇");
const [name, setName] = useState('');
const updateName = (e: any) => setName(e.target.value);
const updateResultText = (result: string) => setResultText(result);
const LAST_USER_KEY = "matrix_client_pc.last_user_id";
function greet() {
Greet(name).then(updateResultText);
export default function App() {
const [userID, setUserID] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const last = localStorage.getItem(LAST_USER_KEY);
if (!last) {
setLoading(false);
return;
}
GetSession(last)
.then((s) => {
if (s && s.has_token) setUserID(s.user_id);
})
.finally(() => setLoading(false));
}, []);
return (
<div id="App">
<img src={logo} id="logo" alt="logo"/>
<div id="result" className="result">{resultText}</div>
<div id="input" className="input-box">
<input id="name" className="input" onChange={updateName} autoComplete="off" name="input" type="text"/>
<button className="btn" onClick={greet}>Greet</button>
</div>
</div>
)
const handleLogin = (uid: string) => {
localStorage.setItem(LAST_USER_KEY, uid);
setUserID(uid);
};
const handleLogout = () => {
localStorage.removeItem(LAST_USER_KEY);
setUserID(null);
};
return (
<Box pos="relative" mih="100vh">
<LoadingOverlay visible={loading} />
{userID ? (
<HomeScreen userID={userID} onLogout={handleLogout} />
) : (
<LoginScreen onLogin={handleLogin} />
)}
</Box>
);
}
export default App
+112
View File
@@ -0,0 +1,112 @@
import { useEffect, useState } from "react";
import {
AppShell,
Avatar,
Badge,
Box,
Button,
Code,
Group,
Paper,
Stack,
Text,
Title,
} from "@mantine/core";
import { IconLogout, IconUserCircle } from "@tabler/icons-react";
import { GetSession, Logout } from "../wailsjs/go/main/MatrixService";
interface Session {
user_id: string;
device_id: string;
homeserver_url: string;
has_token: boolean;
expires_at?: string;
}
export default function HomeScreen({
userID,
onLogout,
}: {
userID: string;
onLogout: () => void;
}) {
const [session, setSession] = useState<Session | null>(null);
useEffect(() => {
GetSession(userID).then((s) => setSession(s as Session | null));
}, [userID]);
async function handleLogout() {
try {
await Logout(userID);
} finally {
onLogout();
}
}
const initials = userID
.replace("@", "")
.split(":")[0]
.slice(0, 2)
.toUpperCase();
return (
<AppShell header={{ height: 56 }} padding="md">
<AppShell.Header>
<Group h="100%" px="md" justify="space-between">
<Group gap="xs">
<IconUserCircle size={22} />
<Text fw={600}>matrix_client_pc</Text>
<Badge size="sm" variant="light" color="violet">
v0.1.0
</Badge>
</Group>
<Button
variant="subtle"
color="gray"
leftSection={<IconLogout size={16} />}
onClick={handleLogout}
>
Logout
</Button>
</Group>
</AppShell.Header>
<AppShell.Main>
<Box maw={720} mx="auto" mt="xl">
<Paper p="xl" radius="lg" withBorder>
<Group gap="lg" align="flex-start">
<Avatar size={64} color="violet" radius="xl">
{initials}
</Avatar>
<Stack gap={6} flex={1}>
<Title order={3}>{userID}</Title>
{session ? (
<Stack gap={4}>
<Text size="sm" c="dimmed">
Device: <Code>{session.device_id || "-"}</Code>
</Text>
<Text size="sm" c="dimmed">
Homeserver: <Code>{session.homeserver_url}</Code>
</Text>
{session.expires_at && (
<Text size="xs" c="dimmed">
Token expira: {session.expires_at}
</Text>
)}
</Stack>
) : (
<Text c="dimmed">Cargando sesion...</Text>
)}
</Stack>
</Group>
</Paper>
<Paper p="md" radius="md" withBorder mt="lg">
<Text size="sm" c="dimmed">
Login OK. Sync + rooms + timeline llegan en issue 0148.
</Text>
</Paper>
</Box>
</AppShell.Main>
</AppShell>
);
}
+73
View File
@@ -0,0 +1,73 @@
import { useState } from "react";
import {
Button,
Card,
Center,
Stack,
Text,
Title,
Code,
Alert,
} from "@mantine/core";
import { IconBrandMatrix, IconAlertCircle } from "@tabler/icons-react";
import { Login } from "../wailsjs/go/main/MatrixService";
export default function LoginScreen({ onLogin }: { onLogin: (uid: string) => void }) {
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleClick() {
setBusy(true);
setError(null);
try {
const uid = await Login();
onLogin(uid);
} catch (e: any) {
setError(String(e?.message ?? e));
} finally {
setBusy(false);
}
}
return (
<Center mih="100vh" p="lg">
<Card shadow="md" padding="xl" radius="lg" withBorder maw={480} w="100%">
<Stack gap="lg" align="center">
<IconBrandMatrix size={48} color="var(--mantine-color-violet-5)" />
<Stack gap={4} align="center">
<Title order={2}>matrix_client_pc</Title>
<Text size="sm" c="dimmed">
Cliente Matrix nativo PC
</Text>
</Stack>
<Text size="sm" ta="center" c="dimmed" maw={360}>
Inicia sesion en <Code>matrix-af2f3d.organic-machine.com</Code> via{" "}
Matrix Authentication Service. Se abrira tu navegador para autorizar.
</Text>
{error && (
<Alert
icon={<IconAlertCircle size={16} />}
color="red"
variant="light"
w="100%"
>
{error}
</Alert>
)}
<Button
size="md"
color="violet"
loading={busy}
onClick={handleClick}
fullWidth
>
Sign in with Matrix
</Button>
<Text size="xs" c="dimmed">
v0.1.0 (issue 0147)
</Text>
</Stack>
</Card>
</Center>
);
}
+20 -11
View File
@@ -1,14 +1,23 @@
import React from 'react'
import {createRoot} from 'react-dom/client'
import './style.css'
import App from './App'
import React from "react";
import { createRoot } from "react-dom/client";
import { MantineProvider, createTheme } from "@mantine/core";
import { Notifications } from "@mantine/notifications";
import App from "./App";
const container = document.getElementById('root')
import "@mantine/core/styles.css";
import "@mantine/notifications/styles.css";
const root = createRoot(container!)
const theme = createTheme({
primaryColor: "violet",
defaultRadius: "md",
fontFamily: "Inter, system-ui, -apple-system, BlinkMacSystemFont, sans-serif",
});
root.render(
<React.StrictMode>
<App/>
</React.StrictMode>
)
createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<MantineProvider defaultColorScheme="dark" theme={theme}>
<Notifications position="top-right" />
<App />
</MantineProvider>
</React.StrictMode>,
);
-26
View File
@@ -1,26 +0,0 @@
html {
background-color: rgba(27, 38, 54, 1);
text-align: center;
color: white;
}
body {
margin: 0;
color: white;
font-family: "Nunito", -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto",
"Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue",
sans-serif;
}
@font-face {
font-family: "Nunito";
font-style: normal;
font-weight: 400;
src: local(""),
url("assets/fonts/nunito-v16-latin-regular.woff2") format("woff2");
}
#app {
height: 100vh;
text-align: center;
}
-4
View File
@@ -1,4 +0,0 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
export function Greet(arg1: string): Promise<string>;
-7
View File
@@ -1,7 +0,0 @@
// @ts-check
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
export function Greet(arg1) {
return window['go']['main']['App']['Greet'](arg1);
}
+49 -7
View File
@@ -21,8 +21,8 @@ export interface Size {
export interface Screen {
isCurrent: boolean;
isPrimary: boolean;
width: number
height: number
width : number
height : number
}
// Environment information such as platform, buildtype, ...
@@ -38,19 +38,23 @@ export interface EnvironmentInfo {
export function EventsEmit(eventName: string, ...data: any): void;
// [EventsOn](https://wails.io/docs/reference/runtime/events#eventson) sets up a listener for the given event name.
export function EventsOn(eventName: string, callback: (...data: any) => void): void;
export function EventsOn(eventName: string, callback: (...data: any) => void): () => void;
// [EventsOnMultiple](https://wails.io/docs/reference/runtime/events#eventsonmultiple)
// sets up a listener for the given event name, but will only trigger a given number times.
export function EventsOnMultiple(eventName: string, callback: (...data: any) => void, maxCallbacks: number): void;
export function EventsOnMultiple(eventName: string, callback: (...data: any) => void, maxCallbacks: number): () => void;
// [EventsOnce](https://wails.io/docs/reference/runtime/events#eventsonce)
// sets up a listener for the given event name, but will only trigger once.
export function EventsOnce(eventName: string, callback: (...data: any) => void): void;
export function EventsOnce(eventName: string, callback: (...data: any) => void): () => void;
// [EventsOff](https://wails.io/docs/reference/runtime/events#eventsff)
// [EventsOff](https://wails.io/docs/reference/runtime/events#eventsoff)
// unregisters the listener for the given event name.
export function EventsOff(eventName: string): void;
export function EventsOff(eventName: string, ...additionalEventNames: string[]): void;
// [EventsOffAll](https://wails.io/docs/reference/runtime/events#eventsoffall)
// unregisters all listeners.
export function EventsOffAll(): void;
// [LogPrint](https://wails.io/docs/reference/runtime/log#logprint)
// logs the given message as a raw message
@@ -124,6 +128,10 @@ export function WindowFullscreen(): void;
// Restores the previous window dimensions and position prior to full screen.
export function WindowUnfullscreen(): void;
// [WindowIsFullscreen](https://wails.io/docs/reference/runtime/window#windowisfullscreen)
// Returns the state of the window, i.e. whether the window is in full screen mode or not.
export function WindowIsFullscreen(): Promise<boolean>;
// [WindowSetSize](https://wails.io/docs/reference/runtime/window#windowsetsize)
// Sets the width and height of the window.
export function WindowSetSize(width: number, height: number): void;
@@ -170,6 +178,10 @@ export function WindowToggleMaximise(): void;
// Restores the window to the dimensions and position prior to maximising.
export function WindowUnmaximise(): void;
// [WindowIsMaximised](https://wails.io/docs/reference/runtime/window#windowismaximised)
// Returns the state of the window, i.e. whether the window is maximised or not.
export function WindowIsMaximised(): Promise<boolean>;
// [WindowMinimise](https://wails.io/docs/reference/runtime/window#windowminimise)
// Minimises the window.
export function WindowMinimise(): void;
@@ -178,6 +190,14 @@ export function WindowMinimise(): void;
// Restores the window to the dimensions and position prior to minimising.
export function WindowUnminimise(): void;
// [WindowIsMinimised](https://wails.io/docs/reference/runtime/window#windowisminimised)
// Returns the state of the window, i.e. whether the window is minimised or not.
export function WindowIsMinimised(): Promise<boolean>;
// [WindowIsNormal](https://wails.io/docs/reference/runtime/window#windowisnormal)
// Returns the state of the window, i.e. whether the window is normal or not.
export function WindowIsNormal(): Promise<boolean>;
// [WindowSetBackgroundColour](https://wails.io/docs/reference/runtime/window#windowsetbackgroundcolour)
// Sets the background colour of the window to the given RGBA colour definition. This colour will show through for all transparent pixels.
export function WindowSetBackgroundColour(R: number, G: number, B: number, A: number): void;
@@ -205,3 +225,25 @@ export function Hide(): void;
// [Show](https://wails.io/docs/reference/runtime/intro#show)
// Shows the application.
export function Show(): void;
// [ClipboardGetText](https://wails.io/docs/reference/runtime/clipboard#clipboardgettext)
// Returns the current text stored on clipboard
export function ClipboardGetText(): Promise<string>;
// [ClipboardSetText](https://wails.io/docs/reference/runtime/clipboard#clipboardsettext)
// Sets a text on the clipboard
export function ClipboardSetText(text: string): Promise<boolean>;
// [OnFileDrop](https://wails.io/docs/reference/runtime/draganddrop#onfiledrop)
// OnFileDrop listens to drag and drop events and calls the callback with the coordinates of the drop and an array of path strings.
export function OnFileDrop(callback: (x: number, y: number ,paths: string[]) => void, useDropTarget: boolean) :void
// [OnFileDropOff](https://wails.io/docs/reference/runtime/draganddrop#dragandddropoff)
// OnFileDropOff removes the drag and drop listeners and handlers.
export function OnFileDropOff() :void
// Check if the file path resolver is available
export function CanResolveFilePaths(): boolean;
// Resolves file paths for an array of files
export function ResolveFilePaths(files: File[]): void
+69 -5
View File
@@ -37,19 +37,23 @@ export function LogFatal(message) {
}
export function EventsOnMultiple(eventName, callback, maxCallbacks) {
window.runtime.EventsOnMultiple(eventName, callback, maxCallbacks);
return window.runtime.EventsOnMultiple(eventName, callback, maxCallbacks);
}
export function EventsOn(eventName, callback) {
EventsOnMultiple(eventName, callback, -1);
return EventsOnMultiple(eventName, callback, -1);
}
export function EventsOff(eventName) {
return window.runtime.EventsOff(eventName);
export function EventsOff(eventName, ...additionalEventNames) {
return window.runtime.EventsOff(eventName, ...additionalEventNames);
}
export function EventsOffAll() {
return window.runtime.EventsOffAll();
}
export function EventsOnce(eventName, callback) {
EventsOnMultiple(eventName, callback, 1);
return EventsOnMultiple(eventName, callback, 1);
}
export function EventsEmit(eventName) {
@@ -97,6 +101,10 @@ export function WindowUnfullscreen() {
window.runtime.WindowUnfullscreen();
}
export function WindowIsFullscreen() {
return window.runtime.WindowIsFullscreen();
}
export function WindowGetSize() {
return window.runtime.WindowGetSize();
}
@@ -141,6 +149,10 @@ export function WindowUnmaximise() {
window.runtime.WindowUnmaximise();
}
export function WindowIsMaximised() {
return window.runtime.WindowIsMaximised();
}
export function WindowMinimise() {
window.runtime.WindowMinimise();
}
@@ -157,6 +169,14 @@ export function ScreenGetAll() {
return window.runtime.ScreenGetAll();
}
export function WindowIsMinimised() {
return window.runtime.WindowIsMinimised();
}
export function WindowIsNormal() {
return window.runtime.WindowIsNormal();
}
export function BrowserOpenURL(url) {
window.runtime.BrowserOpenURL(url);
}
@@ -176,3 +196,47 @@ export function Hide() {
export function Show() {
window.runtime.Show();
}
export function ClipboardGetText() {
return window.runtime.ClipboardGetText();
}
export function ClipboardSetText(text) {
return window.runtime.ClipboardSetText(text);
}
/**
* Callback for OnFileDrop returns a slice of file path strings when a drop is finished.
*
* @export
* @callback OnFileDropCallback
* @param {number} x - x coordinate of the drop
* @param {number} y - y coordinate of the drop
* @param {string[]} paths - A list of file paths.
*/
/**
* OnFileDrop listens to drag and drop events and calls the callback with the coordinates of the drop and an array of path strings.
*
* @export
* @param {OnFileDropCallback} callback - Callback for OnFileDrop returns a slice of file path strings when a drop is finished.
* @param {boolean} [useDropTarget=true] - Only call the callback when the drop finished on an element that has the drop target style. (--wails-drop-target)
*/
export function OnFileDrop(callback, useDropTarget) {
return window.runtime.OnFileDrop(callback, useDropTarget);
}
/**
* OnFileDropOff removes the drag and drop listeners and handlers.
*/
export function OnFileDropOff() {
return window.runtime.OnFileDropOff();
}
export function CanResolveFilePaths() {
return window.runtime.CanResolveFilePaths();
}
export function ResolveFilePaths(files) {
return window.runtime.ResolveFilePaths(files);
}
-11
View File
@@ -5,9 +5,7 @@ go 1.25.0
require github.com/wailsapp/wails/v2 v2.11.0
require (
filippo.io/edwards25519 v1.2.0 // indirect
github.com/bep/debounce v1.2.1 // indirect
github.com/danieljoos/wincred v1.2.3 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/godbus/dbus/v5 v5.2.2 // indirect
github.com/google/uuid v1.6.0 // indirect
@@ -24,24 +22,15 @@ require (
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/rs/zerolog v1.35.1 // indirect
github.com/samber/lo v1.49.1 // indirect
github.com/stretchr/testify v1.11.1 // indirect
github.com/tidwall/gjson v1.19.0 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
github.com/tidwall/sjson v1.2.5 // indirect
github.com/tkrajina/go-reflector v0.5.8 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect
github.com/wailsapp/go-webview2 v1.0.22 // indirect
github.com/wailsapp/mimetype v1.4.1 // indirect
github.com/zalando/go-keyring v0.2.8 // indirect
go.mau.fi/util v0.9.9 // indirect
golang.org/x/crypto v0.51.0 // indirect
golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a // indirect
golang.org/x/net v0.54.0 // indirect
golang.org/x/sys v0.44.0 // indirect
golang.org/x/text v0.37.0 // indirect
maunium.net/go/mautrix v0.28.0 // indirect
)
-34
View File
@@ -1,15 +1,9 @@
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ=
github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@@ -48,22 +42,10 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rs/zerolog v1.35.1 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI=
github.com/rs/zerolog v1.35.1/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw=
github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew=
github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/gjson v1.19.0 h1:xwxm7n691Uf3u5OFjzngavjGTh55KX5q/9w9xHW88JU=
github.com/tidwall/gjson v1.19.0/go.mod h1:V37/opeE/JbLUOfH0QTXiNez2l0RUjYUhpT4szFQAfc=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ=
github.com/tkrajina/go-reflector v0.5.8/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
@@ -76,19 +58,9 @@ github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhw
github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o=
github.com/wailsapp/wails/v2 v2.11.0 h1:seLacV8pqupq32IjS4Y7V8ucab0WZwtK6VvUVxSBtqQ=
github.com/wailsapp/wails/v2 v2.11.0/go.mod h1:jrf0ZaM6+GBc1wRmXsM8cIvzlg0karYin3erahI4+0k=
github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs=
github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0=
go.mau.fi/util v0.9.9 h1:ujDeXCo07HBor5oQLyO1tHklupmqVmPgasc53d7q/NE=
go.mau.fi/util v0.9.9/go.mod h1:pqt4Vcrt+5gcH/CgrHZg11qSx+b34o6mknGzOEA6waY=
golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a h1:+3jdDGGB8NGb1Zktc737jlt3/A5f6UlwSzmvqUuufxw=
golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a/go.mod h1:d2fgXJLVs4dYDHUk5lwMIfzRzSrWCfGZb0ZqeLa/Vcw=
golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -96,18 +68,12 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
maunium.net/go/mautrix v0.28.0 h1:vBakLzf8MAdfED3NzAKiMeKQbc3AQ4EAS03NC+TVMXQ=
maunium.net/go/mautrix v0.28.0/go.mod h1:/a9A7LGaqb9B3nho4tLd28n0EPcCdwpm2dxkxkLLgh0=
+63
View File
@@ -0,0 +1,63 @@
package main
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"time"
)
func tempStoreDir() string {
return filepath.Join(os.TempDir(), "matrix_client_pc_login")
}
func userStoreDir(userID string) string {
cfg, err := os.UserConfigDir()
if err != nil {
cfg = filepath.Join(os.Getenv("HOME"), ".config")
}
slug := strings.NewReplacer("@", "", ":", "_", "/", "_").Replace(userID)
return filepath.Join(cfg, "matrix_client_pc", slug)
}
// whoami issues GET /_matrix/client/v3/account/whoami against the homeserver with the
// provided access_token and returns (user_id, device_id, err). It's used before
// MatrixClientInit because mautrix.NewClient wants user_id up front.
func whoami(ctx context.Context, homeserver, accessToken string) (string, string, error) {
if ctx == nil {
ctx = context.Background()
}
u, err := url.JoinPath(homeserver, "/_matrix/client/v3/account/whoami")
if err != nil {
return "", "", fmt.Errorf("joinpath: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return "", "", err
}
req.Header.Set("Authorization", "Bearer "+accessToken)
cl := &http.Client{Timeout: 15 * time.Second}
resp, err := cl.Do(req)
if err != nil {
return "", "", err
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != 200 {
return "", "", fmt.Errorf("whoami: %s: %s", resp.Status, body)
}
var out struct {
UserID string `json:"user_id"`
DeviceID string `json:"device_id"`
}
if err := json.Unmarshal(body, &out); err != nil {
return "", "", fmt.Errorf("whoami parse: %w", err)
}
return out.UserID, out.DeviceID, nil
}
+11 -10
View File
@@ -1,7 +1,9 @@
package main
import (
"context"
"embed"
"log"
"github.com/wailsapp/wails/v2"
"github.com/wailsapp/wails/v2/pkg/options"
@@ -12,25 +14,24 @@ import (
var assets embed.FS
func main() {
// Create an instance of the app structure
app := NewApp()
ms := NewMatrixService()
// Create application with options
err := wails.Run(&options.App{
Title: "matrix_client_pc",
Width: 1024,
Height: 768,
Width: 1280,
Height: 800,
AssetServer: &assetserver.Options{
Assets: assets,
},
BackgroundColour: &options.RGBA{R: 27, G: 38, B: 54, A: 1},
OnStartup: app.startup,
BackgroundColour: &options.RGBA{R: 26, G: 27, B: 30, A: 1},
OnStartup: func(ctx context.Context) {
ms.SetContext(ctx)
},
Bind: []interface{}{
app,
ms,
},
})
if err != nil {
println("Error:", err.Error())
log.Fatalln("Wails error:", err)
}
}
BIN
View File
Binary file not shown.
+151
View File
@@ -0,0 +1,151 @@
package main
import (
"context"
"errors"
"fmt"
"sync"
"time"
"fn-registry/functions/infra"
)
// Constants are operator-configurable later via settings UI. Hardcoded for issue 0147 MVP.
const (
homeserverURL = "https://matrix-af2f3d.organic-machine.com"
masIssuer = "https://auth-af2f3d.organic-machine.com/"
masClientID = "VDC4XQ2ZKN2TJ0BYVJ54FK7M6Y"
loopbackPort = 8765
keyringServiceName = "fn_registry.matrix_client_pc"
oidcTimeoutSeconds = 300
)
var defaultScopes = []string{
"openid",
"urn:matrix:org.matrix.msc2967.client:api:*",
}
// MatrixService is bound to the Wails frontend.
type MatrixService struct {
ctx context.Context
mu sync.Mutex
store *infra.KeyringTokenStore
}
func NewMatrixService() *MatrixService {
return &MatrixService{
store: infra.NewKeyringTokenStore(keyringServiceName),
}
}
func (s *MatrixService) SetContext(ctx context.Context) {
s.ctx = ctx
}
// SessionView is the safe-to-send JSON for the frontend (no tokens).
type SessionView struct {
UserID string `json:"user_id"`
DeviceID string `json:"device_id"`
HomeserverURL string `json:"homeserver_url"`
HasToken bool `json:"has_token"`
ExpiresAt string `json:"expires_at,omitempty"`
}
// Login launches the OAuth2 PKCE flow against MAS. Blocks until completion or timeout.
// Returns the user_id of the authenticated session.
func (s *MatrixService) Login() (string, error) {
s.mu.Lock()
defer s.mu.Unlock()
cfg := infra.MasOidcLoopbackConfig{
Issuer: masIssuer,
ClientID: masClientID,
Scopes: defaultScopes,
LoopbackPort: loopbackPort,
OpenBrowser: true,
TimeoutSeconds: oidcTimeoutSeconds,
}
res, err := infra.MasOidcLoopback(cfg)
if err != nil {
return "", fmt.Errorf("oidc: %w", err)
}
// Initialize Matrix client to discover user_id + device_id via whoami.
tmpStore := tempStoreDir()
clientCfg := infra.MatrixClientInitConfig{
HomeserverURL: homeserverURL,
// UserID is unknown until whoami. mautrix-go requires it pre-set, but we'll
// use Whoami via the Wails service directly. As shortcut: parse id_token if present.
// For v0.1.0 use a placeholder + Whoami after; mautrix accepts empty UserID, then
// updates after whoami call.
UserID: "",
AccessToken: res.AccessToken,
StoreDir: tmpStore,
EnableCrypto: false,
}
// Pre-fetch user_id by hitting /whoami directly (mautrix requires UserID at NewClient).
userID, deviceID, err := whoami(s.ctx, homeserverURL, res.AccessToken)
if err != nil {
return "", fmt.Errorf("whoami: %w", err)
}
clientCfg.UserID = userID
clientCfg.DeviceID = deviceID
clientCfg.StoreDir = userStoreDir(userID)
if _, err := infra.MatrixClientInit(clientCfg); err != nil {
return "", fmt.Errorf("matrix init: %w", err)
}
tok := infra.Token{
AccessToken: res.AccessToken,
RefreshToken: res.RefreshToken,
UserID: userID,
DeviceID: deviceID,
HomeserverURL: homeserverURL,
Issuer: masIssuer,
ClientID: masClientID,
}
if res.ExpiresIn > 0 {
tok.ExpiresAt = time.Now().Add(time.Duration(res.ExpiresIn) * time.Second)
}
if err := s.store.Save(userID, tok); err != nil {
return "", fmt.Errorf("keyring save: %w", err)
}
return userID, nil
}
// GetSession returns the persisted session for the given user_id (or last-known if empty).
func (s *MatrixService) GetSession(userID string) (*SessionView, error) {
if userID == "" {
// v0.1.0: no multi-account index. Frontend must pass the user_id once known.
return nil, errors.New("user_id required (v0.1.0 multi-account index TODO)")
}
tok, err := s.store.Load(userID)
if err != nil {
if errors.Is(err, infra.ErrNotFound) {
return nil, nil
}
return nil, fmt.Errorf("keyring load: %w", err)
}
view := &SessionView{
UserID: tok.UserID,
DeviceID: tok.DeviceID,
HomeserverURL: tok.HomeserverURL,
HasToken: tok.AccessToken != "",
}
if !tok.ExpiresAt.IsZero() {
view.ExpiresAt = tok.ExpiresAt.Format(time.RFC3339)
}
return view, nil
}
// Logout deletes the persisted token for the given user_id.
func (s *MatrixService) Logout(userID string) error {
s.mu.Lock()
defer s.mu.Unlock()
if userID == "" {
return errors.New("user_id required")
}
return s.store.Delete(userID)
}
+3 -3
View File
@@ -2,9 +2,9 @@
"$schema": "https://wails.io/schemas/config.v2.json",
"name": "matrix_client_pc",
"outputfilename": "matrix_client_pc",
"frontend:install": "npm install",
"frontend:build": "npm run build",
"frontend:dev:watcher": "npm run dev",
"frontend:install": "pnpm install",
"frontend:build": "pnpm build",
"frontend:dev:watcher": "pnpm dev",
"frontend:dev:serverUrl": "auto",
"author": {
"name": "Egutierrez",