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
18 changes: 16 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,22 +10,35 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Added

### Fixed
- Resolve an issue where mods were linking to the Mod site homepage.

- Resolve an issue where some mods were shown as installed, even when they weren't installed.
- Improved error messages when no user is selected or credentials are missing.
- Ensured clientsettings.json has proper file permissions across all operating systems.

### Changed

- Game launch process now validates user authentication before proceeding.
- Session credentials are automatically refreshed or validated before each game launch.

## [0.4.2] - 2025-10-10

### Fixed

- Resolve an issue where mods were linking to the Mod site homepage.
- Resolve an issue where some mods were shown as installed, even when they weren't installed.

## [0.4.1] - 2025-10-10

### Fixed

- Resolve an issue where modPaths don't get updated accordingly when clientsettings.json gets imported.
- Resolves an issue where installation absolute paths don't get updated accordingly.

## [0.4.0] - 2025-10-09

### Added

- Changelog in KeepAChangelog format.
Expand Down Expand Up @@ -197,6 +210,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Stabilized login dialog and authentication flow.

<!-- Version links for diff and release pages -->
[0.4.2]: https://github.com/LovelessCodes/StoryForge/releases/tag/storyforge-v0.4.2
[0.4.1]: https://github.com/LovelessCodes/StoryForge/releases/tag/storyforge-v0.4.1
[0.4.0]: https://github.com/LovelessCodes/StoryForge/releases/tag/storyforge-v0.4.0
[0.3.6]: https://github.com/LovelessCodes/StoryForge/releases/tag/storyforge-v0.3.6
Expand Down
31 changes: 31 additions & 0 deletions src-tauri/src/modules/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,37 @@ pub async fn verify(uid: String, sessionkey: String) -> Result<AuthVerifyRespons
Ok(json_response)
}

#[command]
pub async fn refresh_session(
email: String,
password: String,
uid: String,
sessionkey: String,
) -> Result<GameLoginResponse, UiError> {
// First, verify if the current session is still valid
match verify(uid.clone(), sessionkey.clone()).await {
Ok(verify_response) if verify_response.valid == 1 => {
// Session is still valid, return current credentials
Ok(GameLoginResponse {
sessionkey: Some(sessionkey),
sessionsignature: None, // We don't have this from verify
mptoken: verify_response.mptoken,
uid: Some(uid),
entitlements: None,
playername: None,
hasgameserver: Some(verify_response.hasgameserver),
valid: 1,
reason: None,
prelogintoken: None,
})
}
_ => {
// Session is invalid or expired, perform a fresh login
login(email, password, None, None).await
}
}
}

#[command]
pub async fn login(
email: String,
Expand Down
233 changes: 163 additions & 70 deletions src-tauri/src/modules/installations.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ use serde::Deserialize;
use serde_json::{json, Value};
use std::{
fs::File,
io::{BufRead, BufReader, Read},
io::{BufRead, BufReader, Read, Write},
path::{Path, PathBuf},
process::{Command, Stdio},
thread,
Expand All @@ -11,6 +11,7 @@ use std::{
use tauri::{command, AppHandle, Emitter};
use tauri_plugin_zustand::ManagerExt;

use super::auth::refresh_session;
use super::errors::UiError;
use super::utils::{move_folder, versions_folder};

Expand Down Expand Up @@ -48,7 +49,7 @@ pub struct PlayGameParams {
}

#[command]
pub fn play_game(app: AppHandle, options: Option<PlayGameParams>) -> Result<String, UiError> {
pub async fn play_game(app: AppHandle, options: Option<PlayGameParams>) -> Result<String, UiError> {
let options = options.ok_or_else(|| UiError {
name: "invalid_params".into(),
message: "Invalid play game parameters.".into(),
Expand Down Expand Up @@ -112,80 +113,119 @@ pub fn play_game(app: AppHandle, options: Option<PlayGameParams>) -> Result<Stri
_ => None,
};

if let Some(account) = account {
let settings = json!({
"stringSettings": {
"playeruid": account["uid"].as_str().unwrap_or(""),
"sessionkey": account["sessionkey"].as_str().unwrap_or(""),
"sessionsignature": account["sessionsignature"].as_str().unwrap_or(""),
"playername": account["playername"].as_str().unwrap_or(""),
}
});
let settings_path = pb.join("clientsettings.json");
// It should create the file if it does not exist, but if it exists it should just overwrite the keys
if settings_path.exists() {
let mut existing_settings = String::new();
File::open(&settings_path)
.and_then(|mut f| f.read_to_string(&mut existing_settings))
.map_err(|e| UiError {
name: "read_failed".into(),
message: format!("Failed to read existing clientsettings.json: {e}"),
})?;
let mut existing_json: Value =
serde_json::from_str(&existing_settings).unwrap_or(json!({}));
if let Some(obj) = existing_json.as_object_mut() {
if let Some(string_settings) = obj
.get_mut("stringSettings")
.and_then(|v| v.as_object_mut())
{
for (k, v) in settings["stringSettings"].as_object().unwrap() {
string_settings.insert(k.clone(), v.clone());
}
} else {
obj.insert("stringSettings".into(), settings["stringSettings"].clone());
// Validate that a user is selected
let account = account.ok_or_else(|| UiError {
name: "no_user_selected".into(),
message: "No user account selected. Please select a user before launching the game.".into(),
})?;

// Validate required account fields
let email = account["email"].as_str().ok_or_else(|| UiError {
name: "invalid_account".into(),
message: "User account is missing email.".into(),
})?;

let uid = account["uid"].as_str().ok_or_else(|| UiError {
name: "invalid_account".into(),
message: "User account is missing UID.".into(),
})?;

let sessionkey = account["sessionkey"].as_str().ok_or_else(|| UiError {
name: "invalid_account".into(),
message: "User account is missing session key.".into(),
})?;

// Refresh session to ensure it's valid before launching
let refreshed_session = refresh_session(
email.to_string(),
String::new(), // We don't store passwords, so pass empty string
uid.to_string(),
sessionkey.to_string(),
)
.await
.map_err(|e| UiError {
name: "session_refresh_failed".into(),
message: format!(
"Failed to refresh session: {}. Please sign in again.",
e.message
),
})?;

// Use refreshed credentials or fall back to existing ones
let final_sessionkey = refreshed_session
.sessionkey
.as_deref()
.unwrap_or(sessionkey);
let final_sessionsignature = refreshed_session
.sessionsignature
.as_deref()
.or_else(|| account["sessionsignature"].as_str())
.unwrap_or("");
let final_playername = refreshed_session
.playername
.as_deref()
.or_else(|| account["playername"].as_str())
.unwrap_or("");

let settings = json!({
"stringSettings": {
"playeruid": uid,
"sessionkey": final_sessionkey,
"sessionsignature": final_sessionsignature,
"playername": final_playername,
}
});

let settings_path = pb.join("clientsettings.json");
// It should create the file if it does not exist, but if it exists it should just overwrite the keys
if settings_path.exists() {
let mut existing_settings = String::new();
File::open(&settings_path)
.and_then(|mut f| f.read_to_string(&mut existing_settings))
.map_err(|e| UiError {
name: "read_failed".into(),
message: format!("Failed to read existing clientsettings.json: {e}"),
})?;
let mut existing_json: Value =
serde_json::from_str(&existing_settings).unwrap_or(json!({}));
if let Some(obj) = existing_json.as_object_mut() {
if let Some(string_settings) = obj
.get_mut("stringSettings")
.and_then(|v| v.as_object_mut())
{
for (k, v) in settings["stringSettings"].as_object().unwrap() {
string_settings.insert(k.clone(), v.clone());
}
let mods_path = pb.join("Mods").to_string_lossy().into_owned();
if let Some(string_list_settings) = obj
.get_mut("stringListSettings")
.and_then(|v| v.as_object_mut())
} else {
obj.insert("stringSettings".into(), settings["stringSettings"].clone());
}
let mods_path = pb.join("Mods").to_string_lossy().into_owned();
if let Some(string_list_settings) = obj
.get_mut("stringListSettings")
.and_then(|v| v.as_object_mut())
{
if let Some(mod_paths) = string_list_settings
.get_mut("modPaths")
.and_then(|v| v.as_array_mut())
{
if let Some(mod_paths) = string_list_settings
.get_mut("modPaths")
.and_then(|v| v.as_array_mut())
{
*mod_paths = vec![json!(mods_path), json!("Mods")];
} else {
string_list_settings.insert("modPaths".into(), json!([mods_path, "Mods"]));
}
*mod_paths = vec![json!(mods_path), json!("Mods")];
} else {
obj.insert(
"stringListSettings".into(),
json!({ "modPaths": [mods_path, "Mods"] }),
);
string_list_settings.insert("modPaths".into(), json!([mods_path, "Mods"]));
}
} else {
obj.insert(
"stringListSettings".into(),
json!({ "modPaths": [mods_path, "Mods"] }),
);
}
std::fs::write(
&settings_path,
serde_json::to_string_pretty(&existing_json).unwrap(),
)
.map_err(|e| UiError {
name: "write_failed".into(),
message: format!("Failed to write clientsettings.json: {e}"),
})?;
} else {
std::fs::create_dir_all(settings_path.parent().unwrap()).map_err(|e| UiError {
name: "create_dir_failed".into(),
message: format!("Failed to create directory for clientsettings.json: {e}"),
})?;
std::fs::write(
&settings_path,
serde_json::to_string_pretty(&settings).unwrap(),
)
.map_err(|e| UiError {
name: "write_failed".into(),
message: format!("Failed to write clientsettings.json: {e}"),
})?;
}
write_settings_with_permissions(&settings_path, &existing_json)?;
} else {
std::fs::create_dir_all(settings_path.parent().unwrap()).map_err(|e| UiError {
name: "create_dir_failed".into(),
message: format!("Failed to create directory for clientsettings.json: {e}"),
})?;
write_settings_with_permissions(&settings_path, &settings)?;
}
// Emit a pre-launch event so the UI can show a loading state
let _ = app.emit(
Expand Down Expand Up @@ -340,6 +380,59 @@ pub fn play_game(app: AppHandle, options: Option<PlayGameParams>) -> Result<Stri
Ok("started".into())
}

/// Helper function to write settings file with proper permissions across all OS
fn write_settings_with_permissions(path: &Path, settings: &Value) -> Result<(), UiError> {
// Write the file
let content = serde_json::to_string_pretty(settings).map_err(|e| UiError {
name: "json_error".into(),
message: format!("Failed to serialize settings: {e}"),
})?;

std::fs::write(path, content).map_err(|e| UiError {
name: "write_failed".into(),
message: format!("Failed to write clientsettings.json: {e}"),
})?;

// Set proper file permissions on Unix-like systems
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = std::fs::metadata(path)
.map_err(|e| UiError {
name: "permissions_failed".into(),
message: format!("Failed to get file metadata: {e}"),
})?
.permissions();

// Set to 0644 (rw-r--r--)
perms.set_mode(0o644);
std::fs::set_permissions(path, perms).map_err(|e| UiError {
name: "permissions_failed".into(),
message: format!("Failed to set file permissions: {e}"),
})?;
}

// On Windows, the default permissions are usually adequate
// but we can ensure the file is not read-only
#[cfg(windows)]
{
let mut perms = std::fs::metadata(path)
.map_err(|e| UiError {
name: "permissions_failed".into(),
message: format!("Failed to get file metadata: {e}"),
})?
.permissions();

perms.set_readonly(false);
std::fs::set_permissions(path, perms).map_err(|e| UiError {
name: "permissions_failed".into(),
message: format!("Failed to set file permissions: {e}"),
})?;
}

Ok(())
}

#[command]
pub fn reveal_in_file_explorer(path: String) -> Result<String, UiError> {
let path = Path::new(&path);
Expand Down
16 changes: 16 additions & 0 deletions src/hooks/use-connect-to-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { invoke } from "@tauri-apps/api/core";
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
import { useRef } from "react";
import { toast } from "sonner";
import { useAccountStore } from "@/stores/accounts";
import { useInstallations } from "@/stores/installations";
import { useAddServerToInstallation } from "./use-add-server-to-installation";
import { useCheckServerInInstallation } from "./use-check-server-in-installation";
Expand All @@ -21,6 +22,7 @@ export const useConnectToServer = (
>,
) => {
const { installations, updateLastPlayed } = useInstallations();
const { selectedUser } = useAccountStore();
const { mutateAsync: addServer } = useAddServerToInstallation({
onError: (error) => {
throw error;
Expand All @@ -40,6 +42,20 @@ export const useConnectToServer = (
return useMutation({
...props,
mutationFn: async ({ name, ip, password, installationId, pub }) => {
// Validate that a user is selected before launching
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure sure about them having to select a user, before connecting to a server - Story Forge should be possible to use without signing-in through Story Forge.

if (!selectedUser) {
throw new Error(
"No user account selected. Please sign in or select a user account before connecting to a server.",
);
}

// Validate that the selected user has required credentials
if (!selectedUser.uid || !selectedUser.sessionkey) {
throw new Error(
"Selected user account is missing required credentials. Please sign in again.",
);
}

if (!pub) {
await mutateAsync({
installationId,
Expand Down
Loading