Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 74 additions & 5 deletions src-tauri/src/account.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,17 @@ use isideload::{
app_ids::{AppIdsApi, ListAppIdsResponse},
certificates::{CertificatesApi, DevelopmentCertificate},
developer_session::DeveloperSession,
teams::DeveloperTeam,
},
sideload::{
SideloaderBuilder, TeamSelection, builder::MaxCertsBehavior, sideloader::Sideloader,
},
sideload::{SideloaderBuilder, builder::MaxCertsBehavior, sideloader::Sideloader},
};
use keyring::Entry;
use once_cell::sync::Lazy;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::time::Duration;
use std::{sync::Mutex, time::Duration};
use tauri::{AppHandle, Emitter, Listener, State, Window};
use tauri_plugin_store::StoreExt;
use tracing::debug;
Expand All @@ -22,6 +26,48 @@ use crate::{
sideload::{SideloaderGuard, SideloaderMutex},
};

static TEAM_SELECTION_WINDOW: Lazy<Mutex<Option<Window>>> = Lazy::new(|| Mutex::new(None));

#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DeveloperTeamInfo {
name: Option<String>,
team_id: String,
r#type: Option<String>,
status: Option<String>,
}

impl From<&DeveloperTeam> for DeveloperTeamInfo {
fn from(team: &DeveloperTeam) -> Self {
Self {
name: team.name.clone(),
team_id: team.team_id.clone(),
r#type: team.r#type.clone(),
status: team.status.clone(),
}
}
}

fn prompt_for_team(teams: &Vec<DeveloperTeam>) -> Option<String> {
let window = TEAM_SELECTION_WINDOW.lock().ok()?.as_ref().cloned()?;
let team_infos = teams
.iter()
.map(DeveloperTeamInfo::from)
.collect::<Vec<_>>();

window.emit("team-selection-required", team_infos).ok()?;

let (tx, rx) = std::sync::mpsc::channel::<Option<String>>();
let handler_id = window.listen("team-selection-response", move |event| {
let selection = serde_json::from_str::<Option<String>>(event.payload()).unwrap_or(None);
let _ = tx.send(selection);
});

let result = rx.recv_timeout(Duration::from_secs(300));
window.unlisten(handler_id);
result.unwrap_or(None)
}

#[tauri::command]
pub async fn login_new(
handle: AppHandle,
Expand Down Expand Up @@ -122,6 +168,20 @@ pub fn logged_in_as(sideloader_state: State<'_, SideloaderMutex>) -> Option<Stri
None
}

#[tauri::command]
pub async fn logged_in_team(
sideloader_state: State<'_, SideloaderMutex>,
) -> Result<Option<DeveloperTeamInfo>, AppError> {
let mut sideloader = match SideloaderGuard::take(&sideloader_state) {
Ok(sideloader) => sideloader,
Err(AppError::NotLoggedIn) => return Ok(None),
Err(error) => return Err(error),
};
let team = sideloader.get_mut().get_team().await?;

Ok(Some(DeveloperTeamInfo::from(&team)))
}

#[tauri::command]
pub fn invalidate_account(sideloader_state: State<'_, SideloaderMutex>) {
let mut sideloader_guard = sideloader_state.lock().unwrap();
Expand Down Expand Up @@ -236,14 +296,23 @@ async fn login(
}
};

// TODO: Team Selection

let sideloader = SideloaderBuilder::new(dev_session, email.to_lowercase())
let mut sideloader = SideloaderBuilder::new(dev_session, email.to_lowercase())
.machine_name("iloader".into())
.storage(create_sideloading_storage(app)?)
.team_selection(TeamSelection::PromptOnce(prompt_for_team))
.max_certs_behavior(MaxCertsBehavior::Prompt(Box::new(max_certs_callback)))
.build();

*TEAM_SELECTION_WINDOW
.lock()
.map_err(|_| AppError::Misc("Failed to prepare team selection".into()))? =
Some(window.clone());
let team_result = sideloader.get_team().await;
*TEAM_SELECTION_WINDOW
.lock()
.map_err(|_| AppError::Misc("Failed to clean up team selection".into()))? = None;
team_result?;

debug!("Built sideloader");

Ok(sideloader)
Expand Down
4 changes: 3 additions & 1 deletion src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ mod operation;
use crate::{
account::{
delete_account, delete_app_id, get_certificates, invalidate_account, list_app_ids,
logged_in_as, login_new, login_stored, reset_anisette_state, revoke_certificate,
logged_in_as, logged_in_team, login_new, login_stored, reset_anisette_state,
revoke_certificate,
},
device::{
DeviceInfoMutex, PairingCancelToken, cancel_pairing, list_devices, set_selected_device,
Expand Down Expand Up @@ -107,6 +108,7 @@ pub fn run() {
login_new,
invalidate_account,
logged_in_as,
logged_in_team,
login_stored,
delete_account,
list_devices,
Expand Down
92 changes: 92 additions & 0 deletions src/AppleID.css
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,28 @@
color: #fff;
}

.logged-team {
display: flex;
flex-direction: column;
gap: 0.15rem;
margin-top: 0.45rem;
padding-top: 0.55rem;
border-top: 1px solid rgba(255, 255, 255, 0.14);
}

.logged-team-label {
color: rgba(255, 255, 255, 0.6);
font-size: 0.8rem;
letter-spacing: 0.04em;
text-transform: uppercase;
}

.logged-team-value {
color: #fff;
font-size: 0.98rem;
font-weight: 600;
}

.credentials h3 {
margin-bottom: 0.25em;
margin-top: 1.5em;
Expand Down Expand Up @@ -195,3 +217,73 @@
.credentials-warning {
margin: 0;
}

.team-header {
margin-top: 0;
}

.team-description {
margin-top: 0;
color: var(--text-muted);
}

.team-list {
display: flex;
flex-direction: column;
gap: 0.6rem;
max-height: min(50vh, 420px);
overflow-y: auto;
}

.team-item {
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 0.8rem;
border: 1px solid var(--glass-border);
border-radius: 10px;
background: var(--glass-surface);
cursor: pointer;
transition:
border-color 0.2s ease,
background 0.2s ease;
}

.team-item.selected {
border-color: var(--accent-secondary);
background: rgba(70, 130, 255, 0.14);
}

.team-item input {
margin-top: 0.25rem;
accent-color: var(--accent-secondary);
}

.team-details,
.team-metadata {
display: flex;
flex-direction: column;
}

.team-details {
min-width: 0;
gap: 0.3rem;
}

.team-name {
color: #fff;
font-weight: 600;
}

.team-metadata {
gap: 0.15rem;
color: var(--text-muted);
font-size: 0.85rem;
overflow-wrap: anywhere;
}

.team-buttons {
display: flex;
gap: 0.75rem;
margin-top: 1.25rem;
}
113 changes: 113 additions & 0 deletions src/AppleID.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,13 @@ import { useTranslation } from "react-i18next";

const storePromise = load("data.json");

type DeveloperTeamInfo = {
name: string | null;
teamId: string;
type: string | null;
status: string | null;
};

export const AppleID = ({
loggedInAs,
setLoggedInAs,
Expand All @@ -37,12 +44,19 @@ export const AppleID = ({
const [certs, setCerts] = useState<Certificate[] | null>(null);
const [selectedSerials, setSelectedSerials] = useState<string[]>([]);
const [chooseCertsOpen, setChooseCertsOpen] = useState<boolean>(false);
const [teams, setTeams] = useState<DeveloperTeamInfo[] | null>(null);
const [selectedTeamId, setSelectedTeamId] = useState<string>("");
const [activeTeam, setActiveTeam] = useState<DeveloperTeamInfo | null>(null);
const { err } = useError();

useEffect(() => {
let getLoggedInAs = async () => {
let account = await invoke<string | null>("logged_in_as");
setLoggedInAs(account);
let team = account
? await invoke<DeveloperTeamInfo | null>("logged_in_team")
: null;
setActiveTeam(team);
};
let getStoredIds = async () => {
const store = await storePromise;
Expand Down Expand Up @@ -97,6 +111,28 @@ export const AppleID = ({
};
}, []);

const teamListenerAdded = useRef<boolean>(false);
const teamUnlisten = useRef<() => void>(() => {});

useEffect(() => {
if (!teamListenerAdded.current) {
(async () => {
const unlistenFn = await listen<DeveloperTeamInfo[]>(
"team-selection-required",
(event) => {
setSelectedTeamId("");
setTeams(event.payload);
},
);
teamUnlisten.current = unlistenFn;
})();
teamListenerAdded.current = true;
}
return () => {
teamUnlisten.current();
};
}, []);

return (
<>
<h2 style={{ marginTop: 0 }}>{t("apple_id.title")}</h2>
Expand All @@ -106,6 +142,16 @@ export const AppleID = ({
<div className="logged-info">
<span className="logged-label">{t("apple_id.logged_in_as")}</span>
<span className="logged-value">{loggedInAs}</span>
{activeTeam && (
<span className="logged-team">
<span className="logged-team-label">
{t("apple_id.developer_team")}
</span>
<span className="logged-team-value">
{activeTeam.name ?? activeTeam.teamId}
</span>
</span>
)}
</div>
<div className="action-row">
<button
Expand Down Expand Up @@ -266,6 +312,73 @@ export const AppleID = ({
</div>
)}
</div>
<Modal sizeFit isOpen={teams !== null} zIndex={2000}>
<h2 className="team-header">{t("apple_id.team_selection_title")}</h2>
<p className="team-description">
{t("apple_id.team_selection_prompt")}
</p>
<div className="team-list" role="radiogroup">
{teams?.map((team) => (
<label
key={team.teamId}
className={`team-item${
selectedTeamId === team.teamId ? " selected" : ""
}`}
>
<input
type="radio"
name="developer-team"
value={team.teamId}
checked={selectedTeamId === team.teamId}
onChange={() => setSelectedTeamId(team.teamId)}
/>
<span className="team-details">
<span className="team-name">
{team.name ?? t("apple_id.unnamed_team")}
</span>
<span className="team-metadata">
<span>
{t("apple_id.team_id")}: {team.teamId}
</span>
{team.type && (
<span>
{t("apple_id.team_type")}: {team.type}
</span>
)}
{team.status && (
<span>
{t("apple_id.team_status")}: {team.status}
</span>
)}
</span>
</span>
</label>
))}
</div>
<div className="team-buttons">
<button
className="action-button primary"
disabled={!selectedTeamId}
onClick={async () => {
await emit("team-selection-response", selectedTeamId);
setTeams(null);
setSelectedTeamId("");
}}
>
{t("common.confirm")}
</button>
<button
className="action-button danger"
onClick={async () => {
await emit("team-selection-response", null);
setTeams(null);
setSelectedTeamId("");
}}
>
{t("common.cancel")}
</button>
</div>
</Modal>
<Modal sizeFit isOpen={tfaOpen} zIndex={2000}>
<h2>{t("apple_id.two_factor_title")}</h2>
<p>{t("apple_id.two_factor_prompt")}</p>
Expand Down
Loading