diff options
-rw-r--r-- | .cargo/config.toml | 2 | ||||
-rw-r--r-- | .gitignore | 1 | ||||
-rw-r--r-- | Cargo.lock | 33 | ||||
-rw-r--r-- | app/Cargo.toml | 1 | ||||
-rw-r--r-- | app/src/commands/accounts.rs | 84 | ||||
-rw-r--r-- | app/src/commands/mod.rs | 1 | ||||
-rw-r--r-- | app/src/lib.rs | 65 | ||||
-rwxr-xr-x | generate-bindings.sh | 12 | ||||
-rw-r--r-- | ui/src/root.tsx | 17 | ||||
-rw-r--r-- | ui/util/generate-tauri-bindings.js | 131 |
10 files changed, 279 insertions, 68 deletions
diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..97d20f6 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,2 @@ +[env] +TS_RS_EXPORT_DIR = { value = "./ui/bindings/", relative = true } diff --git a/.gitignore b/.gitignore index e666e00..7676437 100644 --- a/.gitignore +++ b/.gitignore @@ -2,5 +2,6 @@ /app/gen /ui/dist /ui/node_modules +/ui/bindings .DS_Store diff --git a/Cargo.lock b/Cargo.lock index 214e87e..4ba468f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1122,6 +1122,7 @@ dependencies = [ "tauri-plugin-single-instance", "tokio", "toml 0.8.20", + "ts-rs", "url", "uuid", ] @@ -4310,6 +4311,15 @@ dependencies = [ ] [[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] name = "thin-slice" version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -4630,6 +4640,29 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] +name = "ts-rs" +version = "10.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e640d9b0964e9d39df633548591090ab92f7a4567bc31d3891af23471a3365c6" +dependencies = [ + "lazy_static", + "thiserror 2.0.11", + "ts-rs-macros", +] + +[[package]] +name = "ts-rs-macros" +version = "10.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e9d8656589772eeec2cf7a8264d9cda40fb28b9bc53118ceb9e8c07f8f38730" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.98", + "termcolor", +] + +[[package]] name = "typeid" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" diff --git a/app/Cargo.toml b/app/Cargo.toml index e0def75..accf7aa 100644 --- a/app/Cargo.toml +++ b/app/Cargo.toml @@ -22,5 +22,6 @@ tauri-plugin-deep-link = "2" tauri-plugin-single-instance = {version = "2", features = ["deep-link"] } tokio = "1.43.0" toml = "0.8.20" +ts-rs = "10.1" url = "2.5.4" uuid = {version="1.13.1", features= ["v4"] } diff --git a/app/src/commands/accounts.rs b/app/src/commands/accounts.rs new file mode 100644 index 0000000..ef9c203 --- /dev/null +++ b/app/src/commands/accounts.rs @@ -0,0 +1,84 @@ +use tauri::{State, AppHandle}; +use crate::oauth::SigninResult; +use crate::persistence::Account as PersistedAccount; +use crate::AppState; +use tauri_plugin_opener::OpenerExt; +use ts_rs::TS; +use serde::Serialize; + +#[derive(TS, Serialize)] +#[ts(export)] +pub struct Account { + username: String, + domain: String, +} + +impl From<PersistedAccount> for Account { + fn from(value: PersistedAccount) -> Self { + Self { + username: value.username, + domain: value.server_domain, + } + } +} + +#[tauri::command] +pub async fn get_all_accounts(state: State<'_, AppState>) -> Result<Vec<Account>, String> { + Ok(state.persistence_controller.get_all_accounts().await.into_iter().map(|a| a.into()).collect()) +} + +#[tauri::command] +pub async fn start_account_auth(app_handle: AppHandle, state: State<'_, AppState>, instance_domain: &str) -> Result<Vec<String>, ()> { + let add_result = state.oauth_controller.start_authorization(instance_domain).await; + + let state_nonce = match add_result { + Ok(result) => { + let opener = app_handle.opener(); + if let Err(_) = opener.open_url(result.auth_url, None::<&str>) { + println!("Could not open authentication page"); + return Err(()) + } + + result.auth_state + } + Err(err) => { + println!("Error adding server: {err}"); + return Err(()) + } + }; + + let signin_result = state.oauth_controller.finish_authorization(state_nonce).await; + match signin_result { + Ok(SigninResult {server_domain, username}) => { + println!("Signed in successfully @{username}@{server_domain}"); + Ok(vec!(server_domain, username)) + } + Err(err) => { + println!("Error completing signin: {err}"); + Err(()) + } + } +} + +#[tauri::command] +pub async fn get_self(state: State<'_, AppState>, server_domain: String, username: String) -> Result<String, String> { + let client = reqwest::Client::builder().user_agent("Foxfleet v0.0.1").build().expect("Could not construct client"); + + let account = state.persistence_controller.get_account(&username, &server_domain).await + .ok_or(format!("No account for @{username}@{server_domain}"))?; + + let api_key = state.persistence_controller.get_credential(account.api_credential_id).await?; + + if let Ok(result) = client.get("https://social.tempest.dev/api/v1/accounts/verify_credentials") + .bearer_auth(api_key) + .send().await { + if let Ok(result) = result.text().await { + return Ok(result) + } else { + return Err("Error decoding response".to_string()); + } + } else { + return Err("Error fetching account".to_string()); + } +} + diff --git a/app/src/commands/mod.rs b/app/src/commands/mod.rs new file mode 100644 index 0000000..9bb4894 --- /dev/null +++ b/app/src/commands/mod.rs @@ -0,0 +1 @@ +pub mod accounts; diff --git a/app/src/lib.rs b/app/src/lib.rs index 91071fa..879a4f3 100644 --- a/app/src/lib.rs +++ b/app/src/lib.rs @@ -4,8 +4,10 @@ use oauth::{OAuthController, SigninResult}; mod persistence; use persistence::{Account, PersistenceController}; + +pub mod commands; + use tauri_plugin_deep_link::DeepLinkExt; -use tauri_plugin_opener::OpenerExt; use tauri::{Manager, State, AppHandle}; @@ -41,67 +43,8 @@ pub fn run() { }); Ok(()) }) - .invoke_handler(tauri::generate_handler![start_account_auth, get_self, get_all_accounts]) + .invoke_handler(tauri::generate_handler![commands::accounts::start_account_auth, commands::accounts::get_self, commands::accounts::get_all_accounts]) .run(tauri::generate_context!()) .expect("Error starting") } -#[tauri::command] -async fn get_all_accounts(state: State<'_, AppState>) -> Result<Vec<Account>, String> { - Ok(state.persistence_controller.get_all_accounts().await) -} - -#[tauri::command] -async fn start_account_auth(app_handle: AppHandle, state: State<'_, AppState>, instance_domain: &str) -> Result<Vec<String>, ()> { - let add_result = state.oauth_controller.start_authorization(instance_domain).await; - - let state_nonce = match add_result { - Ok(result) => { - let opener = app_handle.opener(); - if let Err(_) = opener.open_url(result.auth_url, None::<&str>) { - println!("Could not open authentication page"); - return Err(()) - } - - result.auth_state - } - Err(err) => { - println!("Error adding server: {err}"); - return Err(()) - } - }; - - let signin_result = state.oauth_controller.finish_authorization(state_nonce).await; - match signin_result { - Ok(SigninResult {server_domain, username}) => { - println!("Signed in successfully @{username}@{server_domain}"); - Ok(vec!(server_domain, username)) - } - Err(err) => { - println!("Error completing signin: {err}"); - Err(()) - } - } -} - -#[tauri::command] -async fn get_self(state: State<'_, AppState>, server_domain: String, username: String) -> Result<String, String> { - let client = reqwest::Client::builder().user_agent("Foxfleet v0.0.1").build().expect("Could not construct client"); - - let account = state.persistence_controller.get_account(&username, &server_domain).await - .ok_or(format!("No account for @{username}@{server_domain}"))?; - - let api_key = state.persistence_controller.get_credential(account.api_credential_id).await?; - - if let Ok(result) = client.get("https://social.tempest.dev/api/v1/accounts/verify_credentials") - .bearer_auth(api_key) - .send().await { - if let Ok(result) = result.text().await { - return Ok(result) - } else { - return Err("Error decoding response".to_string()); - } - } else { - return Err("Error fetching account".to_string()); - } -} diff --git a/generate-bindings.sh b/generate-bindings.sh new file mode 100755 index 0000000..bab3021 --- /dev/null +++ b/generate-bindings.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash + +cd "$(dirname "$0")" + +pushd app +cargo +nightly rustdoc --package foxfleet --lib -- --output-format json -Z unstable-options +cargo test export_bindings +popd + +pushd ui +node util/generate-tauri-bindings.js +popd diff --git a/ui/src/root.tsx b/ui/src/root.tsx index 13b3021..2026dde 100644 --- a/ui/src/root.tsx +++ b/ui/src/root.tsx @@ -1,26 +1,29 @@ import { useEffect, useState } from 'react'; -import { invoke } from '@tauri-apps/api/core'; +import { Accounts } from '../bindings/' +import { Account } from '../bindings/Account'; export default function Root() { - const [existingAccounts, setExistingAccounts] = useState<any>([]) + const [existingAccounts, setExistingAccounts] = useState<Account[]>([]) const [signedIn, setSignedIn] = useState<{serverDomain: string, username: string} | null>(null) const [accountData, setAccountData] = useState('') async function signIn() { - let [serverDomain, username] = await invoke('start_account_auth', {instanceDomain: 'social.tempest.dev'}) as string[] + let [serverDomain, username] = await Accounts.startAccountAuth('social.tempest.dev') + setSignedIn({serverDomain, username}) } async function getSelf() { if (!signedIn) throw new Error("Not signed in") const {serverDomain, username} = signedIn; - let result = await invoke('get_self', {serverDomain, username}) as string + let result = await Accounts.getSelf(serverDomain, username) setAccountData(JSON.parse(result)) } useEffect(() => { (async () => { - setExistingAccounts(await invoke('get_all_accounts')) + const result = await Accounts.getAllAccounts() + setExistingAccounts(result) })() }, []) @@ -29,9 +32,9 @@ export default function Root() { {!signedIn ? ( <> <p>Existing accounts:</p> - {existingAccounts.map(({username, server_domain}: {username: string, server_domain: string}) => ( + {existingAccounts.map(({username, domain}) => ( <> - <button key={username + server_domain} onClick={() => setSignedIn({serverDomain: server_domain, username})}>@{username}@{server_domain}</button> + <button key={username + domain} onClick={() => setSignedIn({serverDomain: domain, username})}>@{username}@{domain}</button> <br/> </> ))} diff --git a/ui/util/generate-tauri-bindings.js b/ui/util/generate-tauri-bindings.js new file mode 100644 index 0000000..11c8b61 --- /dev/null +++ b/ui/util/generate-tauri-bindings.js @@ -0,0 +1,131 @@ +import path from 'node:path' +import {existsSync, readFileSync, writeFileSync} from 'node:fs' + +const typedefPath = path.join(import.meta.dirname, '../../target/doc/foxfleet_applib.json') +const outputPath = path.join(import.meta.dirname, '../bindings/index.ts') + +if (!existsSync(typedefPath)) { + console.error(`Could not find rustdoc type definitions`) + process.exit(1) +} + +const commandPrefix = '::foxfleet_applib::commands' +const {root, index} = JSON.parse(readFileSync(typedefPath)) + +function traverseModuleForFunctions(startIndex, namespace = '') { + const node = index[startIndex] + + if (!node) return [] + + // If it's a module + if (node.inner?.module) { + const childItems = node.inner.module.items + return childItems.map(childIndex => traverseModuleForFunctions(childIndex, namespace + '::' + node.name)) + } + + // If it's a function + if (node.inner?.function) { + return {...node, namespace} + } + + return [] +} + +function getParameters(functionDef) { + let params = [] + + const skipped = ['tauri::State', 'tauri::AppHandle'] + for (const [name, typedef] of functionDef.inner.function.sig.inputs) { + const typeName = typedef.resolved_path?.name + if (skipped.includes(typeName)) continue + + + const type = typedef.resolved_path ? convertType(typedef.resolved_path) : convertType(typedef.borrowed_ref) + params.push({name, type}) + } + + return params +} + +function getReturnType(functionDef) { + if (functionDef.inner.function.sig.output.resolved_path.name === "Result") + return convertType(functionDef.inner.function.sig.output.resolved_path.args.angle_bracketed.args[0].type.resolved_path) + + return convertType(functionDef.inner.function.sig.output.resolved_path) +} + +let imports = [] + +function convertType(typeDef) { + if (typeof typeDef === 'undefined') return undefined + + const {name, id, args: {angle_bracketed: {args = []} = {}} = {}} = typeDef + + if(args.length && name !== 'Vec') throw new Error(`Unknown generic type ${name}`) + if(name === 'Vec' && !args) throw new Errror(`Unknown inner type for vec`) + + if(name === 'Vec') return convertType(args[0].type.resolved_path) + '[]' + if(name === 'Result') return `Promise<${convertType(args[0].type.resolved_path)}>` + + if(name === 'String' || typeDef?.type?.primitive === 'str') return 'string' + + // Otherwise look up the definition and make sure it has the ![derive(TS)] + const def = index[id] + if (!def.attrs || !def.attrs.includes('#[ts(export)]')) { + throw new Error(`Complex type ${name} does not have Typescript definitions exported`) + } + + imports.push(name) + return name +} + +function convertCamelCase(name) { + return name.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase()) +} + +function convertUpperCamelCase(name) { + return name.replace(/(_|^)([a-z])/g, (_, _first, letter) => letter.toUpperCase()) +} + +const publicFunctions = traverseModuleForFunctions(root).flat(Infinity) +const commandFunctions = publicFunctions + .filter(({namespace}) => namespace.startsWith(commandPrefix)) + .map(({namespace, ...rest}) => ({...rest, namespace: namespace.replace(commandPrefix + '::', '')})) + +const namespaces = {} +for(const func of commandFunctions) { + if(!namespaces[func.namespace]) + namespaces[func.namespace] = [] + + namespaces[func.namespace].push(func) +} + +const invokeLine = `import { invoke } from '@tauri-apps/api/core';` +const namespaceBlocks = Object.keys(namespaces).map(namespace => { + const functions = namespaces[namespace] + const functionBlocks = functions.map(func => { + const params = getParameters(func) + const parametersString = params.map(({name, type}) => `${name}: ${type}`).join(', ') + let returnTypeString = getReturnType(func) + if (!/^Promise<.*>$/.test(returnTypeString)) + returnTypeString = `Promise<${returnTypeString}>` + + return ` + export async function ${convertCamelCase(func.name)}(${parametersString}): ${returnTypeString} { + return await invoke('${func.name}', {${params.map(param => convertCamelCase(param.name) + ': ' + param.name).join(', ')}}) as any as ${returnTypeString} + }` + }) + + return `export namespace ${convertUpperCamelCase(namespace)} {\n${functionBlocks.join('\n')}\n}` +}) +// This needs to be last so it gets populated by the above calls +const importLines = imports.map(type => `import type {${type}} from './${type}.ts'`) + +const fileData = ` +${invokeLine} +${importLines} + +${namespaceBlocks.join('\n\n')} +` + +writeFileSync(outputPath, fileData) |