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)
|