Skip to content

Commit 61ec00e

Browse files
Th0rgalThomas Musikclaude
authored
feat(accounts): add export/import commands for portable account transfer (#43)
* feat(accounts): add export/import commands for portable account transfer Adds two new subcommands to `shard account`: - `shard account export [-o output.json]` - Exports all accounts including tokens to a portable JSON file. Useful for transferring accounts between machines or backing up before system changes. - `shard account import <file> [--replace]` - Imports accounts from an exported JSON file. By default, skips accounts that already exist (same UUID). Use `--replace` to overwrite existing accounts with imported data. The exported JSON format includes: - Account metadata (uuid, username, xuid) - MSA tokens (access_token, refresh_token, expires_at) - Minecraft tokens (access_token, expires_at) Security warning is displayed after export reminding users to keep the file secure and delete after use, as it contains sensitive authentication tokens. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * refactor(accounts): simplify merge_accounts logic Collapse nested if statements in merge_accounts to satisfy clippy and improve code readability. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(accounts): properly propagate load_accounts errors during import Replace `unwrap_or_default()` with `?` to avoid silently dropping existing accounts when there's a keyring error during import. This matches the error handling pattern used by all other `load_accounts` call sites. Fixes Bugbot review comment about potential silent data loss. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * chore: bump version to 0.1.25 - Update version in launcher/Cargo.toml - Update version in desktop/src-tauri/Cargo.toml - Update version in desktop/package.json - Update metainfo with release history Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Thomas Musik <music@music2music.fr> Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent a9b3c14 commit 61ec00e

File tree

6 files changed

+155
-4
lines changed

6 files changed

+155
-4
lines changed

desktop/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "shard-ui",
33
"private": true,
4-
"version": "0.1.24",
4+
"version": "0.1.25",
55
"type": "module",
66
"scripts": {
77
"dev": "vite",

desktop/src-tauri/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "shard_ui"
3-
version = "0.1.24"
3+
version = "0.1.25"
44
description = "Shard launcher UI"
55
authors = ["you"]
66
edition = "2021"

launcher/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "shard"
3-
version = "0.1.24"
3+
version = "0.1.25"
44
edition = "2024"
55
description = "A minimal, content-addressed Minecraft launcher"
66
license = "MIT"

launcher/src/accounts.rs

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -389,3 +389,104 @@ pub fn set_active(accounts: &mut Accounts, id: &str) -> bool {
389389
}
390390
false
391391
}
392+
393+
/// Portable account format for export/import (includes tokens in the JSON)
394+
#[derive(Debug, Clone, Serialize, Deserialize)]
395+
pub struct ExportedAccounts {
396+
#[serde(default)]
397+
pub active: Option<String>,
398+
#[serde(default)]
399+
pub accounts: Vec<ExportedAccount>,
400+
}
401+
402+
#[derive(Debug, Clone, Serialize, Deserialize)]
403+
pub struct ExportedAccount {
404+
pub uuid: String,
405+
pub username: String,
406+
#[serde(default, skip_serializing_if = "Option::is_none")]
407+
pub xuid: Option<String>,
408+
pub msa: MsaTokens,
409+
pub minecraft: MinecraftTokens,
410+
}
411+
412+
impl From<&Account> for ExportedAccount {
413+
fn from(account: &Account) -> Self {
414+
ExportedAccount {
415+
uuid: account.uuid.clone(),
416+
username: account.username.clone(),
417+
xuid: account.xuid.clone(),
418+
msa: account.msa.clone(),
419+
minecraft: account.minecraft.clone(),
420+
}
421+
}
422+
}
423+
424+
impl From<ExportedAccount> for Account {
425+
fn from(exported: ExportedAccount) -> Self {
426+
Account {
427+
uuid: exported.uuid,
428+
username: exported.username,
429+
xuid: exported.xuid,
430+
msa: exported.msa,
431+
minecraft: exported.minecraft,
432+
}
433+
}
434+
}
435+
436+
/// Export all accounts to a portable JSON format (includes tokens)
437+
pub fn export_accounts(accounts: &Accounts) -> ExportedAccounts {
438+
ExportedAccounts {
439+
active: accounts.active.clone(),
440+
accounts: accounts.accounts.iter().map(ExportedAccount::from).collect(),
441+
}
442+
}
443+
444+
/// Export accounts to a JSON file
445+
pub fn export_accounts_to_file(accounts: &Accounts, path: &Path) -> Result<()> {
446+
let exported = export_accounts(accounts);
447+
let data = serde_json::to_string_pretty(&exported).context("failed to serialize accounts for export")?;
448+
fs::write(path, data).with_context(|| format!("failed to write export file: {}", path.display()))?;
449+
Ok(())
450+
}
451+
452+
/// Import accounts from a portable JSON format
453+
pub fn import_accounts(exported: ExportedAccounts) -> Accounts {
454+
Accounts {
455+
active: exported.active,
456+
accounts: exported.accounts.into_iter().map(Account::from).collect(),
457+
}
458+
}
459+
460+
/// Import accounts from a JSON file
461+
pub fn import_accounts_from_file(path: &Path) -> Result<Accounts> {
462+
let data = fs::read_to_string(path)
463+
.with_context(|| format!("failed to read import file: {}", path.display()))?;
464+
let exported: ExportedAccounts = serde_json::from_str(&data)
465+
.with_context(|| format!("failed to parse import file: {}", path.display()))?;
466+
Ok(import_accounts(exported))
467+
}
468+
469+
/// Merge imported accounts into existing accounts, optionally replacing duplicates
470+
pub fn merge_accounts(existing: &mut Accounts, imported: Accounts, replace: bool) -> usize {
471+
let mut count = 0;
472+
for account in imported.accounts {
473+
if let Some(existing_account) = existing
474+
.accounts
475+
.iter_mut()
476+
.find(|a| a.uuid == account.uuid)
477+
{
478+
if replace {
479+
*existing_account = account;
480+
count += 1;
481+
}
482+
} else {
483+
existing.accounts.push(account);
484+
count += 1;
485+
}
486+
}
487+
// Update active if not set and imported has one
488+
if existing.active.is_none() {
489+
existing.active = imported.active;
490+
}
491+
count
492+
}

launcher/src/main.rs

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,10 @@ use reqwest::blocking::Client;
44
use reqwest::header::CONTENT_TYPE;
55
use semver::Version;
66
use serde::Deserialize;
7-
use shard::accounts::{delete_account_tokens, load_accounts, remove_account, save_accounts, set_active};
7+
use shard::accounts::{
8+
delete_account_tokens, export_accounts_to_file, import_accounts_from_file, load_accounts,
9+
merge_accounts, remove_account, save_accounts, set_active,
10+
};
811
use shard::auth::request_device_code;
912
use shard::config::{load_config, save_config};
1013
use shard::content_store::{ContentStore, ContentType, Platform, SearchOptions};
@@ -237,6 +240,20 @@ enum AccountCommand {
237240
Remove { id: String },
238241
/// Show account profile info (skin, cape)
239242
Info { id: Option<String> },
243+
/// Export accounts to a JSON file (includes tokens for portability)
244+
Export {
245+
/// Output file path (default: accounts_export.json)
246+
#[arg(short, long, default_value = "accounts_export.json")]
247+
output: PathBuf,
248+
},
249+
/// Import accounts from a JSON file
250+
Import {
251+
/// Input file path
252+
input: PathBuf,
253+
/// Replace existing accounts with same UUID
254+
#[arg(long)]
255+
replace: bool,
256+
},
240257
/// Skin management
241258
Skin {
242259
#[command(subcommand)]
@@ -1124,6 +1141,34 @@ fn handle_account_command(paths: &Paths, command: AccountCommand) -> Result<()>
11241141
}
11251142
}
11261143
}
1144+
AccountCommand::Export { output } => {
1145+
let accounts = load_accounts(paths)?;
1146+
if accounts.accounts.is_empty() {
1147+
bail!("no accounts to export");
1148+
}
1149+
export_accounts_to_file(&accounts, &output)?;
1150+
println!(
1151+
"exported {} account(s) to {}",
1152+
accounts.accounts.len(),
1153+
output.display()
1154+
);
1155+
println!("⚠️ WARNING: This file contains sensitive authentication tokens.");
1156+
println!(" Keep it secure and delete after use.");
1157+
}
1158+
AccountCommand::Import { input, replace } => {
1159+
let mut existing = load_accounts(paths)?;
1160+
let imported = import_accounts_from_file(&input)?;
1161+
let import_count = imported.accounts.len();
1162+
let added = merge_accounts(&mut existing, imported, replace);
1163+
save_accounts(paths, &existing)?;
1164+
println!(
1165+
"imported {added} account(s) from {} ({import_count} in file)",
1166+
input.display()
1167+
);
1168+
if added < import_count && !replace {
1169+
println!("tip: use --replace to overwrite existing accounts with same UUID");
1170+
}
1171+
}
11271172
AccountCommand::Skin { command } => handle_skin_command(paths, command)?,
11281173
AccountCommand::Cape { command } => handle_cape_command(paths, command)?,
11291174
}

packaging/flathub/md.thomas.shard.launcher.metainfo.xml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,11 @@
6161
</screenshot>
6262
</screenshots>
6363
<releases>
64+
<release version="0.1.25" date="2026-02-09">
65+
<description>
66+
<p>Add account export/import for portable account transfer</p>
67+
</description>
68+
</release>
6469
<release version="0.1.24" date="2026-01-29">
6570
<description>
6671
<p>Fix Flatpak build with correct binary name for Tauri v2</p>

0 commit comments

Comments
 (0)