Add Slint iOS repository browser

This commit is contained in:
Georg Bauer
2026-07-30 13:40:21 +02:00
parent 7f7c04fc0a
commit 6a0c5d7b4c
11 changed files with 5504 additions and 28 deletions

4255
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,11 +1,11 @@
[workspace]
members = ["crates/gitea", "crates/cli"]
members = ["crates/gitea", "crates/cli", "crates/app"]
resolver = "3"
[workspace.package]
edition = "2024"
license = "MIT"
rust-version = "1.85"
rust-version = "1.92"
[workspace.dependencies]
reqwest = { version = "0.13", default-features = false, features = ["json", "rustls"] }
@@ -14,3 +14,4 @@ serde_json = "1"
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
rpassword = "7"
serde_yaml = "0.9"
slint = { version = "=1.17.1", default-features = false, features = ["std", "backend-winit", "renderer-skia", "compat-1-2"] }

View File

@@ -9,7 +9,8 @@ application.
- `gotcha_gitea`: reusable asynchronous Gitea API client with the complete
typed Gitea 1.25 API and model surface
- `gotcha`: CLI for typed common operations and arbitrary API requests
- iOS/Slint app: next crate, bundle identifier `de.rfc1437.gotcha`
- `gotcha-app`: Slint iOS client for servers, owned repositories, favorites,
open issues, and issue details; bundle identifier `de.rfc1437.gotcha`
The generated API modules and models cover every Gitea 1.25 operation. The
low-level request API remains available for newer instance-specific endpoints;
@@ -57,5 +58,21 @@ accepted as command-line arguments or environment variables.
1. Exercise and type API areas in the CLI: repositories, issues and pull
requests, Actions, notifications, organizations, packages, administration.
2. Add the Slint shell and move proven read workflows into touch-first screens.
3. Add write workflows and iOS integrations such as secure token storage,
sharing, notifications, and background refresh.
3. Add write workflows and iOS integrations such as sharing, notifications,
and background refresh.
## iOS app
Server tokens are kept in the Apple Keychain; the JSON preferences contain only
server metadata and favorites. The app requires Xcode, XcodeGen, an installed
iOS Simulator runtime, and Rust's
`aarch64-apple-ios` and `aarch64-apple-ios-sim` targets. Generate the project
after installing those prerequisites:
```sh
cd ios
xcodegen generate
open Gotcha.xcodeproj
```
For a fast host-side check, run `cargo test -p gotcha-app`.

19
crates/app/Cargo.toml Normal file
View File

@@ -0,0 +1,19 @@
[package]
name = "gotcha-app"
version = "0.1.0"
description = "Lightweight Slint client for Gitea on iOS"
edition.workspace = true
license.workspace = true
rust-version.workspace = true
[[bin]]
name = "gotcha-app"
path = "src/main.rs"
[dependencies]
gotcha_gitea = { path = "../gitea" }
serde.workspace = true
serde_json.workspace = true
security-framework = "3"
slint.workspace = true
tokio.workspace = true

814
crates/app/src/main.rs Normal file
View File

@@ -0,0 +1,814 @@
use std::{
collections::BTreeSet,
env, fs,
future::Future,
path::PathBuf,
sync::{Arc, Mutex, OnceLock},
};
use gotcha_gitea::{Client, apis, models};
use security_framework::passwords::{get_generic_password, set_generic_password};
use serde::{Deserialize, Serialize};
use slint::{ModelRc, VecModel};
slint::slint! {
import { Button, LineEdit, ListView, ScrollView, Spinner } from "std-widgets.slint";
export struct ServerRow { name: string, url: string }
export struct RepositoryRow {
name: string, owner: string, description: string, meta: string, favorite: bool,
}
export struct IssueRow {
number: int, title: string, summary: string, meta: string,
}
component Header inherits Rectangle {
in property <string> title;
in property <bool> can_back;
callback back();
height: 58px;
width: 100%;
background: #f8f8fa;
border-color: #dedee3;
border-width: 0px;
if can_back: TouchArea {
x: 8px; width: 54px;
clicked => { root.back(); }
Text { text: ""; color: #0879e1; font-size: 38px; vertical-alignment: center; }
}
Text {
text: root.title;
horizontal-alignment: center;
vertical-alignment: center;
font-size: 18px;
font-weight: 600;
color: #151518;
}
Rectangle { y: parent.height - 1px; height: 1px; background: #dedee3; }
}
component EdgeBack inherits SwipeGestureHandler {
callback back();
width: 24px;
height: 100%;
handle-swipe-right: true;
swiped => { root.back(); }
}
component EmptyState inherits VerticalLayout {
in property <string> title;
in property <string> detail;
alignment: center;
spacing: 8px;
Text { text: root.title; font-size: 21px; font-weight: 600; horizontal-alignment: center; color: #1c1c1e; }
Text { text: root.detail; font-size: 15px; horizontal-alignment: center; wrap: word-wrap; color: #69696f; }
}
export component AppWindow inherits Window {
title: "Gotcha";
preferred-width: 390px;
preferred-height: 844px;
background: #f2f2f7;
in-out property <string> page: "servers";
in-out property <[ServerRow]> servers;
in-out property <[RepositoryRow]> repositories;
in-out property <[IssueRow]> issues;
in-out property <string> server_title;
in-out property <string> repository_title;
in-out property <string> issue_title;
in-out property <string> issue_meta;
in-out property <string> issue_body;
in-out property <bool> loading;
in-out property <string> error;
callback show_add_server();
callback save_server(string, string, string);
callback select_server(int);
callback select_repository(string, string);
callback toggle_favorite(string, string);
callback select_issue(int);
callback back();
if page == "servers": Rectangle {
x: root.safe-area-insets.left; y: root.safe-area-insets.top;
width: root.width - root.safe-area-insets.left - root.safe-area-insets.right;
height: root.height - root.safe-area-insets.top - root.safe-area-insets.bottom;
background: #f2f2f7;
Header { x: 0; y: 0; title: "Servers"; can_back: false; }
if root.servers.length > 0: ListView {
x: 16px; y: 76px; width: parent.width - 32px; height: parent.height - 154px;
for server[index] in root.servers: Rectangle {
height: 76px;
background: #ffffff;
border-color: #dedee3;
border-width: 1px;
border-radius: 12px;
TouchArea { clicked => { root.select_server(index); } }
Text { x: 16px; y: 13px; width: parent.width - 54px; text: server.name; font-size: 17px; font-weight: 600; color: #19191c; overflow: elide; }
Text { x: 16px; y: 42px; width: parent.width - 54px; text: server.url; font-size: 13px; color: #6c6c72; overflow: elide; }
Text { x: parent.width - 28px; text: ""; font-size: 27px; color: #a0a0a6; vertical-alignment: center; }
}
}
Button { x: 16px; y: parent.height - 66px; width: parent.width - 32px; height: 48px; text: "Add Server"; clicked => { root.show_add_server(); } }
}
if page == "add": Rectangle {
x: root.safe-area-insets.left; y: root.safe-area-insets.top;
width: root.width - root.safe-area-insets.left - root.safe-area-insets.right;
height: root.height - root.safe-area-insets.top - root.safe-area-insets.bottom;
background: #f2f2f7;
Header { x: 0; y: 0; title: "Add Server"; can_back: true; back => { root.back(); } }
VerticalLayout {
x: 20px; y: 92px; width: parent.width - 40px; height: 260px; spacing: 10px;
Text { text: "Name"; font-size: 13px; color: #66666c; }
server_name := LineEdit { placeholder-text: "Work"; }
Text { text: "Server URL"; font-size: 13px; color: #66666c; }
server_url := LineEdit { placeholder-text: "https://gitea.example.com"; }
Text { text: "Access token"; font-size: 13px; color: #66666c; }
server_token := LineEdit { placeholder-text: "Token"; input-type: InputType.password; }
}
Button {
x: 20px; y: 380px; width: parent.width - 40px; height: 48px;
text: root.loading ? "Connecting…" : "Add Server";
enabled: !root.loading;
clicked => { root.save_server(server_name.text, server_url.text, server_token.text); }
}
EdgeBack { x: 0; y: 0; back => { root.back(); } }
}
if page == "repositories": Rectangle {
x: root.safe-area-insets.left; y: root.safe-area-insets.top;
width: root.width - root.safe-area-insets.left - root.safe-area-insets.right;
height: root.height - root.safe-area-insets.top - root.safe-area-insets.bottom;
background: #f2f2f7;
Header { x: 0; y: 0; title: root.server_title; can_back: true; back => { root.back(); } }
if !root.loading && root.repositories.length == 0: EmptyState {
x: 34px; width: parent.width - 68px; y: 220px; height: 160px;
title: "No repositories";
detail: "This account does not own any repositories on this server.";
}
ListView {
x: 20px; y: 70px; width: parent.width - 40px; height: parent.height - 82px;
for repo in root.repositories: Rectangle {
height: 104px;
background: #ffffff;
border-color: #dedee3;
border-width: 1px;
border-radius: 12px;
TouchArea { clicked => { root.select_repository(repo.owner, repo.name); } }
Text { x: 15px; y: 11px; width: parent.width - 62px; text: repo.name; font-size: 17px; font-weight: 600; color: #17171a; overflow: elide; }
Text { x: 15px; y: 39px; width: parent.width - 30px; text: repo.description; font-size: 14px; color: #56565c; overflow: elide; }
Text { x: 15px; y: 71px; width: parent.width - 30px; text: repo.meta; font-size: 12px; color: #7b7b82; overflow: elide; }
TouchArea {
x: parent.width - 52px; y: 2px; width: 48px; height: 48px;
clicked => { root.toggle_favorite(repo.owner, repo.name); }
Text { text: repo.favorite ? "" : ""; color: repo.favorite ? #f2a900 : #888890; font-size: 27px; horizontal-alignment: center; vertical-alignment: center; }
}
}
}
EdgeBack { x: 0; y: 0; back => { root.back(); } }
}
if page == "issues": Rectangle {
x: root.safe-area-insets.left; y: root.safe-area-insets.top;
width: root.width - root.safe-area-insets.left - root.safe-area-insets.right;
height: root.height - root.safe-area-insets.top - root.safe-area-insets.bottom;
background: #f2f2f7;
Header { x: 0; y: 0; title: root.repository_title; can_back: true; back => { root.back(); } }
if !root.loading && root.issues.length == 0: EmptyState {
x: 34px; width: parent.width - 68px; y: 220px; height: 160px;
title: "No open issues";
detail: "Everything is clear for this repository.";
}
ListView {
x: 20px; y: 70px; width: parent.width - 40px; height: parent.height - 82px;
for issue in root.issues: Rectangle {
height: 112px;
background: #ffffff;
border-color: #dedee3;
border-width: 1px;
border-radius: 12px;
TouchArea { clicked => { root.select_issue(issue.number); } }
Text { x: 15px; y: 11px; width: parent.width - 44px; text: issue.title; font-size: 16px; font-weight: 600; color: #17171a; overflow: elide; }
Text { x: 15px; y: 39px; width: parent.width - 30px; height: 38px; text: issue.summary; font-size: 13px; color: #56565c; wrap: word-wrap; overflow: elide; }
Text { x: 15px; y: 86px; width: parent.width - 30px; text: issue.meta; font-size: 12px; color: #7b7b82; overflow: elide; }
}
}
EdgeBack { x: 0; y: 0; back => { root.back(); } }
}
if page == "issue": Rectangle {
x: root.safe-area-insets.left; y: root.safe-area-insets.top;
width: root.width - root.safe-area-insets.left - root.safe-area-insets.right;
height: root.height - root.safe-area-insets.top - root.safe-area-insets.bottom;
background: #f2f2f7;
Header { x: 0; y: 0; title: "Issue"; can_back: true; back => { root.back(); } }
ScrollView {
x: 18px; y: 78px; width: parent.width - 36px; height: parent.height - 96px;
VerticalLayout {
width: parent.width;
spacing: 12px;
alignment: start;
Text { text: root.issue_title; font-size: 23px; font-weight: 650; color: #161619; wrap: word-wrap; }
Text { text: root.issue_meta; font-size: 13px; color: #707077; wrap: word-wrap; }
Rectangle { height: 1px; background: #d8d8dd; }
Text { text: root.issue_body; font-size: 16px; color: #252529; wrap: word-wrap; }
}
}
EdgeBack { x: 0; y: 0; back => { root.back(); } }
}
if root.loading: Rectangle {
width: 100%; height: 100%;
background: #55ffffff;
TouchArea { }
Spinner { width: 38px; height: 38px; x: (parent.width - self.width) / 2; y: (parent.height - self.height) / 2; }
}
if root.error != "": Rectangle {
x: 14px; y: parent.height - 94px; width: parent.width - 28px; height: 66px;
background: #d93d35; border-radius: 12px;
Text { x: 14px; width: parent.width - 28px; text: root.error; color: white; font-size: 13px; wrap: word-wrap; vertical-alignment: center; }
TouchArea { clicked => { root.error = ""; } }
}
}
}
#[derive(Clone, Deserialize, Serialize)]
struct Server {
name: String,
url: String,
#[serde(skip)]
token: String,
}
#[derive(Default, Deserialize, Serialize)]
struct Preferences {
#[serde(default)]
servers: Vec<Server>,
#[serde(default)]
favorites: BTreeSet<String>,
}
#[derive(Clone)]
struct RepositoryData {
owner: String,
name: String,
description: String,
language: String,
open_issues: i64,
updated: String,
}
#[derive(Default)]
struct State {
preferences: Preferences,
active_server: Option<usize>,
active_repository: Option<(String, String)>,
repositories: Vec<RepositoryData>,
issues: Vec<models::Issue>,
}
static RUNTIME: OnceLock<tokio::runtime::Runtime> = OnceLock::new();
fn main() -> Result<(), Box<dyn std::error::Error>> {
let ui = AppWindow::new()?;
let (preferences, startup_error) = match load_preferences() {
Ok(preferences) => (preferences, None),
Err(error) => (Preferences::default(), Some(error)),
};
let state = Arc::new(Mutex::new(State {
preferences,
..Default::default()
}));
refresh_servers(&ui, &state.lock().unwrap().preferences);
if let Some(error) = startup_error {
ui.set_error(error.into());
}
wire_callbacks(&ui, state.clone());
let server_count = state.lock().unwrap().preferences.servers.len();
if server_count == 1 {
open_server(&ui, state, 0);
}
ui.run()?;
Ok(())
}
fn wire_callbacks(ui: &AppWindow, state: Arc<Mutex<State>>) {
ui.on_show_add_server({
let weak = ui.as_weak();
move || {
if let Some(ui) = weak.upgrade() {
ui.set_error("".into());
ui.set_page("add".into());
}
}
});
ui.on_save_server({
let weak = ui.as_weak();
let state = state.clone();
move |name, url, token| {
let server = match validate_server(name.as_str(), url.as_str(), token.as_str()) {
Ok(server) => server,
Err(error) => {
if let Some(ui) = weak.upgrade() {
ui.set_error(error.into());
}
return;
}
};
if let Some(ui) = weak.upgrade() {
ui.set_error("".into());
ui.set_loading(true);
}
let weak = weak.clone();
let state = state.clone();
spawn(async move {
let result = match Client::new(&server.url, Some(&server.token)) {
Ok(client) => client
.current_user()
.await
.map_err(|error| error.to_string()),
Err(error) => Err(error.to_string()),
};
post(move || {
let Some(ui) = weak.upgrade() else { return };
ui.set_loading(false);
match result {
Ok(_) => {
if let Err(error) = save_server_token(&server) {
ui.set_error(error.into());
return;
}
let index = {
let mut state = state.lock().unwrap();
state.preferences.servers.push(server);
if let Err(error) = save_preferences(&state.preferences) {
ui.set_error(error.into());
return;
}
refresh_servers(&ui, &state.preferences);
state.preferences.servers.len() - 1
};
open_server(&ui, state, index);
}
Err(error) => ui.set_error(error.into()),
}
});
});
}
});
ui.on_select_server({
let weak = ui.as_weak();
let state = state.clone();
move |index| {
if let Some(ui) = weak.upgrade() {
open_server(&ui, state.clone(), index as usize);
}
}
});
ui.on_select_repository({
let weak = ui.as_weak();
let state = state.clone();
move |owner, name| {
if let Some(ui) = weak.upgrade() {
open_repository(&ui, state.clone(), owner.to_string(), name.to_string());
}
}
});
ui.on_toggle_favorite({
let weak = ui.as_weak();
let state = state.clone();
move |owner, name| {
let mut state = state.lock().unwrap();
let Some(server) = state
.active_server
.and_then(|i| state.preferences.servers.get(i))
else {
return;
};
let key = favorite_key(&server.url, owner.as_str(), name.as_str());
if !state.preferences.favorites.remove(&key) {
state.preferences.favorites.insert(key);
}
if let Err(error) = save_preferences(&state.preferences) {
if let Some(ui) = weak.upgrade() {
ui.set_error(error.into());
}
return;
}
if let Some(ui) = weak.upgrade() {
refresh_repositories(&ui, &state);
}
}
});
ui.on_select_issue({
let weak = ui.as_weak();
let state = state.clone();
move |number| {
let issue = state
.lock()
.unwrap()
.issues
.iter()
.find(|issue| issue.number == Some(number as i64))
.cloned();
if let (Some(ui), Some(issue)) = (weak.upgrade(), issue) {
show_issue(&ui, &issue);
}
}
});
ui.on_back({
let weak = ui.as_weak();
move || {
let Some(ui) = weak.upgrade() else { return };
ui.set_error("".into());
ui.set_page(match ui.get_page().as_str() {
"issue" => "issues".into(),
"issues" => "repositories".into(),
"repositories" | "add" => "servers".into(),
_ => "servers".into(),
});
}
});
}
fn open_server(ui: &AppWindow, state: Arc<Mutex<State>>, index: usize) {
let server = {
let mut state = state.lock().unwrap();
let Some(server) = state.preferences.servers.get(index).cloned() else {
return;
};
state.active_server = Some(index);
state.repositories.clear();
server
};
ui.set_server_title(server.name.clone().into());
ui.set_repositories(empty_model());
ui.set_page("repositories".into());
ui.set_error("".into());
ui.set_loading(true);
let weak = ui.as_weak();
spawn(async move {
let result = load_repositories(&server).await;
post(move || {
let Some(ui) = weak.upgrade() else { return };
ui.set_loading(false);
match result {
Ok(repositories) => {
let mut state = state.lock().unwrap();
state.repositories = repositories;
refresh_repositories(&ui, &state);
}
Err(error) => ui.set_error(error.into()),
}
});
});
}
fn open_repository(ui: &AppWindow, state: Arc<Mutex<State>>, owner: String, name: String) {
let server = {
let mut state = state.lock().unwrap();
state.active_repository = Some((owner.clone(), name.clone()));
state.issues.clear();
state
.active_server
.and_then(|i| state.preferences.servers.get(i))
.cloned()
};
let Some(server) = server else { return };
ui.set_repository_title(name.clone().into());
ui.set_issues(empty_model());
ui.set_page("issues".into());
ui.set_error("".into());
ui.set_loading(true);
let weak = ui.as_weak();
spawn(async move {
let result = load_issues(&server, &owner, &name).await;
post(move || {
let Some(ui) = weak.upgrade() else { return };
ui.set_loading(false);
match result {
Ok(issues) => {
let mut state = state.lock().unwrap();
state.issues = issues;
refresh_issues(&ui, &state.issues);
}
Err(error) => ui.set_error(error.into()),
}
});
});
}
async fn load_repositories(server: &Server) -> Result<Vec<RepositoryData>, String> {
let client =
Client::new(&server.url, Some(&server.token)).map_err(|error| error.to_string())?;
let configuration = client.configuration();
let user = client
.current_user()
.await
.map_err(|error| error.to_string())?;
let login = user.login.unwrap_or_default();
let repositories = apis::user_api::user_current_list_repos(&configuration, Some(1), Some(100))
.await
.map_err(|error| error.to_string())?;
Ok(repositories
.into_iter()
.filter(|repository| {
repository
.owner
.as_ref()
.and_then(|owner| owner.login.as_deref())
== Some(login.as_str())
})
.filter_map(|repository| {
Some(RepositoryData {
owner: repository.owner?.login?,
name: repository.name?,
description: repository
.description
.filter(|text| !text.is_empty())
.unwrap_or_else(|| "No description".into()),
language: repository
.language
.filter(|text| !text.is_empty())
.unwrap_or_else(|| "Unknown language".into()),
open_issues: repository.open_issues_count.unwrap_or_default(),
updated: compact_date(repository.updated_at.as_deref()),
})
})
.collect())
}
async fn load_issues(
server: &Server,
owner: &str,
repository: &str,
) -> Result<Vec<models::Issue>, String> {
let client =
Client::new(&server.url, Some(&server.token)).map_err(|error| error.to_string())?;
apis::issue_api::issue_list_issues(
&client.configuration(),
owner,
repository,
Some("open"),
None,
None,
Some("issues"),
None,
None,
None,
None,
None,
None,
Some(1),
Some(100),
)
.await
.map_err(|error| error.to_string())
}
fn refresh_servers(ui: &AppWindow, preferences: &Preferences) {
ui.set_servers(model(
preferences
.servers
.iter()
.map(|server| ServerRow {
name: server.name.clone().into(),
url: server.url.clone().into(),
})
.collect(),
));
}
fn refresh_repositories(ui: &AppWindow, state: &State) {
let server_url = state
.active_server
.and_then(|i| state.preferences.servers.get(i))
.map(|server| server.url.as_str())
.unwrap_or_default();
let mut repositories = state.repositories.clone();
repositories.sort_by_key(|repository| {
(
!state.preferences.favorites.contains(&favorite_key(
server_url,
&repository.owner,
&repository.name,
)),
repository.name.to_lowercase(),
)
});
ui.set_repositories(model(
repositories
.into_iter()
.map(|repository| {
let favorite = state.preferences.favorites.contains(&favorite_key(
server_url,
&repository.owner,
&repository.name,
));
RepositoryRow {
name: repository.name.into(),
owner: repository.owner.into(),
description: repository.description.into(),
meta: format!(
"{} · {} open · {}",
repository.language, repository.open_issues, repository.updated
)
.into(),
favorite,
}
})
.collect(),
));
}
fn refresh_issues(ui: &AppWindow, issues: &[models::Issue]) {
ui.set_issues(model(
issues
.iter()
.map(|issue| IssueRow {
number: issue.number.unwrap_or_default() as i32,
title: issue
.title
.clone()
.unwrap_or_else(|| "Untitled issue".into())
.into(),
summary: issue
.body
.as_deref()
.map(summary)
.unwrap_or_else(|| "No description".into())
.into(),
meta: issue_meta(issue).into(),
})
.collect(),
));
}
fn show_issue(ui: &AppWindow, issue: &models::Issue) {
ui.set_issue_title(
issue
.title
.clone()
.unwrap_or_else(|| "Untitled issue".into())
.into(),
);
ui.set_issue_meta(issue_meta(issue).into());
ui.set_issue_body(
issue
.body
.clone()
.filter(|body| !body.is_empty())
.unwrap_or_else(|| "No description provided.".into())
.into(),
);
ui.set_page("issue".into());
}
fn issue_meta(issue: &models::Issue) -> String {
let author = issue
.user
.as_ref()
.and_then(|user| user.login.as_deref())
.unwrap_or("unknown");
format!(
"#{} · {} · updated {} · {} comments",
issue.number.unwrap_or_default(),
author,
compact_date(issue.updated_at.as_deref()),
issue.comments.unwrap_or_default()
)
}
fn summary(body: &str) -> String {
body.split_whitespace()
.take(24)
.collect::<Vec<_>>()
.join(" ")
}
fn compact_date(date: Option<&str>) -> String {
date.and_then(|date| date.get(..10))
.unwrap_or("unknown")
.to_string()
}
fn favorite_key(server: &str, owner: &str, repository: &str) -> String {
format!("{server}|{owner}/{repository}")
}
fn validate_server(name: &str, url: &str, token: &str) -> Result<Server, String> {
let name = name.trim();
let url = url.trim().trim_end_matches('/');
let token = token.trim();
if name.is_empty() {
return Err("Give this server a name.".into());
}
if token.is_empty() {
return Err("Enter an access token.".into());
}
Client::new(url, Some(token)).map_err(|error| error.to_string())?;
Ok(Server {
name: name.into(),
url: url.into(),
token: token.into(),
})
}
fn preferences_path() -> Result<PathBuf, String> {
let home = env::var_os("HOME").ok_or("Cannot find the app data directory.")?;
Ok(PathBuf::from(home).join("Library/Application Support/Gotcha/preferences.json"))
}
fn load_preferences() -> Result<Preferences, String> {
let path = preferences_path()?;
if !path.exists() {
return Ok(Preferences::default());
}
let mut preferences: Preferences =
serde_json::from_slice(&fs::read(&path).map_err(|error| error.to_string())?)
.map_err(|error| format!("Cannot read {}: {error}", path.display()))?;
for server in &mut preferences.servers {
server.token = String::from_utf8(
get_generic_password("de.rfc1437.gotcha", &keychain_account(server))
.map_err(|error| format!("Cannot read the token for {}: {error}", server.name))?,
)
.map_err(|_| format!("The token for {} is not valid text.", server.name))?;
}
Ok(preferences)
}
fn save_preferences(preferences: &Preferences) -> Result<(), String> {
let path = preferences_path()?;
let parent = path.parent().ok_or("Invalid app data directory.")?;
fs::create_dir_all(parent).map_err(|error| error.to_string())?;
let temporary = path.with_extension("tmp");
fs::write(
&temporary,
serde_json::to_vec(preferences).map_err(|error| error.to_string())?,
)
.map_err(|error| error.to_string())?;
fs::rename(&temporary, &path).map_err(|error| error.to_string())
}
fn save_server_token(server: &Server) -> Result<(), String> {
set_generic_password(
"de.rfc1437.gotcha",
&keychain_account(server),
server.token.as_bytes(),
)
.map_err(|error| format!("Cannot save the token for {}: {error}", server.name))
}
fn keychain_account(server: &Server) -> String {
format!("{}|{}", server.name, server.url)
}
fn runtime() -> &'static tokio::runtime::Runtime {
RUNTIME.get_or_init(|| tokio::runtime::Runtime::new().expect("Tokio runtime"))
}
fn spawn(future: impl Future<Output = ()> + Send + 'static) {
runtime().spawn(future);
}
fn post(callback: impl FnOnce() + Send + 'static) {
let _ = slint::invoke_from_event_loop(callback);
}
fn model<T: Clone + 'static>(rows: Vec<T>) -> ModelRc<T> {
ModelRc::new(VecModel::from(rows))
}
fn empty_model<T: Clone + 'static>() -> ModelRc<T> {
model(Vec::new())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn validates_servers_and_builds_stable_favorite_keys() {
assert!(validate_server("Work", "https://gitea.example.com/", "secret").is_ok());
assert!(validate_server("", "https://gitea.example.com", "secret").is_err());
assert!(validate_server("Work", "file:///tmp/gitea", "secret").is_err());
assert_eq!(
favorite_key("https://gitea.example.com", "octo", "demo"),
"https://gitea.example.com|octo/demo"
);
}
}

10
ios/Gotcha.entitlements Normal file
View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>keychain-access-groups</key>
<array>
<string>$(AppIdentifierPrefix)$(CFBundleIdentifier)</string>
</array>
</dict>
</plist>

View File

@@ -0,0 +1,293 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 77;
objects = {
/* Begin PBXFileReference section */
5FB3250A93766966A60A685E /* Gotcha.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Gotcha.app; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */
/* Begin PBXGroup section */
A8558BC8DD12191B80F52573 = {
isa = PBXGroup;
children = (
F059299C038F3CAFCE470831 /* Products */,
);
sourceTree = "<group>";
};
F059299C038F3CAFCE470831 /* Products */ = {
isa = PBXGroup;
children = (
5FB3250A93766966A60A685E /* Gotcha.app */,
);
name = Products;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
60C9DF1AB4A7858833302BA5 /* Gotcha */ = {
isa = PBXNativeTarget;
buildConfigurationList = 7E8DF3DDA64C8C998FF3BB1D /* Build configuration list for PBXNativeTarget "Gotcha" */;
buildPhases = (
409DBF67E9C2743801B8F8A4 /* Sources */,
6334AD54CF86DAD2EC201EBC /* Build Rust app */,
);
buildRules = (
);
dependencies = (
);
name = Gotcha;
packageProductDependencies = (
);
productName = Gotcha;
productReference = 5FB3250A93766966A60A685E /* Gotcha.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
C30A6F1B592A907D96D6AB40 /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = YES;
LastUpgradeCheck = 1430;
TargetAttributes = {
};
};
buildConfigurationList = 807BC8918EC1B0F31384F2B7 /* Build configuration list for PBXProject "Gotcha" */;
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
Base,
en,
);
mainGroup = A8558BC8DD12191B80F52573;
minimizedProjectReferenceProxies = 1;
preferredProjectObjectVersion = 77;
productRefGroup = F059299C038F3CAFCE470831 /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
60C9DF1AB4A7858833302BA5 /* Gotcha */,
);
};
/* End PBXProject section */
/* Begin PBXShellScriptBuildPhase section */
6334AD54CF86DAD2EC201EBC /* Build Rust app */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
);
name = "Build Rust app";
outputFileListPaths = (
);
outputPaths = (
"$(TARGET_BUILD_DIR)/$(EXECUTABLE_PATH)",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "./build_for_ios_with_cargo.bash gotcha-app\n";
};
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
409DBF67E9C2743801B8F8A4 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin XCBuildConfiguration section */
2F34E9C13F86A9A4A187A561 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = Gotcha.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
INFOPLIST_FILE = Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = de.rfc1437.gotcha;
SDKROOT = iphoneos;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
5698E9C0385C353DCF255CDD /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = Gotcha.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
INFOPLIST_FILE = Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = de.rfc1437.gotcha;
SDKROOT = iphoneos;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
};
6638D4C74FE4AF377E805256 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"$(inherited)",
"DEBUG=1",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
};
name = Debug;
};
AA31619080EBF75C58A16A96 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O";
SWIFT_VERSION = 5.0;
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
7E8DF3DDA64C8C998FF3BB1D /* Build configuration list for PBXNativeTarget "Gotcha" */ = {
isa = XCConfigurationList;
buildConfigurations = (
2F34E9C13F86A9A4A187A561 /* Debug */,
5698E9C0385C353DCF255CDD /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Debug;
};
807BC8918EC1B0F31384F2B7 /* Build configuration list for PBXProject "Gotcha" */ = {
isa = XCConfigurationList;
buildConfigurations = (
6638D4C74FE4AF377E805256 /* Debug */,
AA31619080EBF75C58A16A96 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Debug;
};
/* End XCConfigurationList section */
};
rootObject = C30A6F1B592A907D96D6AB40 /* Project object */;
}

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>

30
ios/Info.plist Normal file
View File

@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Gotcha</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>UILaunchScreen</key>
<dict/>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
</array>
</dict>
</plist>

View File

@@ -0,0 +1,48 @@
#!/usr/bin/env bash
set -euo pipefail
export PATH="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:$PATH:$HOME/.cargo/bin"
export RUSTC="$(rustup which --toolchain stable rustc)"
export RUSTDOC="$(rustup which --toolchain stable rustdoc)"
export CARGO_TARGET_DIR="$DERIVED_FILE_DIR/cargo"
export CARGO_PROFILE_RELEASE_DEBUG="${CARGO_PROFILE_RELEASE_DEBUG:-1}"
if [[ "$CONFIGURATION" == "Debug" ]]; then
profile=debug
else
profile=release
fi
if [[ "${LLVM_TARGET_TRIPLE_SUFFIX:-}" == "-simulator" ]]; then
separator=$'\x1f'
entitlement_flags="-Clink-arg=-Wl,-sectcreate,__TEXT,__entitlements,$LD_ENTITLEMENTS_SECTION"
entitlement_flags+="$separator-Clink-arg=-Wl,-sectcreate,__TEXT,__ents_der,$LD_ENTITLEMENTS_SECTION_DER"
export CARGO_ENCODED_RUSTFLAGS="${CARGO_ENCODED_RUSTFLAGS:+$CARGO_ENCODED_RUSTFLAGS$separator}$entitlement_flags"
fi
executables=()
for arch in $ARCHS; do
if [[ "$arch" == "arm64" && "${LLVM_TARGET_TRIPLE_SUFFIX:-}" == "-simulator" ]]; then
target=aarch64-apple-ios-sim
elif [[ "$arch" == "arm64" ]]; then
target=aarch64-apple-ios
elif [[ "$arch" == "x86_64" ]]; then
target=x86_64-apple-ios
else
echo "Unsupported iOS architecture: $arch" >&2
exit 1
fi
if [[ "$profile" == "debug" ]]; then
rustup run stable cargo build --locked --target "$target" --bin "$1"
else
rustup run stable cargo build --locked --release --target "$target" --bin "$1"
fi
executables+=("$CARGO_TARGET_DIR/$target/$profile/$1")
done
lipo -create -output "$TARGET_BUILD_DIR/$EXECUTABLE_PATH" "${executables[@]}"
if [[ -n "${DWARF_DSYM_FOLDER_PATH:-}" && -n "${DWARF_DSYM_FILE_NAME:-}" ]]; then
dsymutil "$TARGET_BUILD_DIR/$EXECUTABLE_PATH" -o "$DWARF_DSYM_FOLDER_PATH/$DWARF_DSYM_FILE_NAME"
fi

28
ios/project.yml Normal file
View File

@@ -0,0 +1,28 @@
name: Gotcha
options:
bundleIdPrefix: de.rfc1437
settings:
ENABLE_USER_SCRIPT_SANDBOXING: NO
targets:
Gotcha:
type: application
platform: iOS
deploymentTarget: "17.0"
settings:
PRODUCT_BUNDLE_IDENTIFIER: de.rfc1437.gotcha
CODE_SIGN_ENTITLEMENTS: Gotcha.entitlements
info:
path: Info.plist
properties:
CFBundleDisplayName: Gotcha
UILaunchScreen: {}
UISupportedInterfaceOrientations:
- UIInterfaceOrientationPortrait
sources: []
postCompileScripts:
- name: Build Rust app
basedOnDependencyAnalysis: false
script: |
./build_for_ios_with_cargo.bash gotcha-app
outputFiles:
- $(TARGET_BUILD_DIR)/$(EXECUTABLE_PATH)