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
6 changes: 6 additions & 0 deletions .github/workflows/source-regressions.yml
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,12 @@ jobs:
run: |
dotnet restore tests/builder_workers/BuilderWorkersTests.csproj --configfile tests/builder_workers/NuGet.Config
dotnet run --project tests/builder_workers/BuilderWorkersTests.csproj --configuration Release --no-restore -- (Get-Command python).Source
- name: Check the launcher's profiles
if: ${{ !cancelled() && runner.os == 'Windows' }}
shell: pwsh
run: |
dotnet restore tests/launcher_profiles/LauncherProfilesTests.csproj --configfile tests/launcher_profiles/NuGet.Config
dotnet run --project tests/launcher_profiles/LauncherProfilesTests.csproj --configuration Release --no-restore
- name: Compile Windows Builder
if: ${{ !cancelled() && runner.os == 'Windows' }}
run: dotnet build builder/HookshotBuilder/HookshotBuilder.csproj --configuration Debug -p:NuGetAudit=false
Expand Down
31 changes: 31 additions & 0 deletions builder/HookshotBuilder/App.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,37 @@
</Setter>
</Style>

<!-- a row of a drop-down menu (the launcher's Manage profiles), as a ChoiceItem looks -->
<Style x:Key="MenuRow" TargetType="Button">
<Setter Property="Foreground" Value="{StaticResource Ink}"/>
<Setter Property="FontFamily" Value="{StaticResource Ui}"/>
<Setter Property="FontSize" Value="13"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="FocusVisualStyle" Value="{x:Null}"/>
<Setter Property="HorizontalContentAlignment" Value="Left"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border x:Name="Box" Background="Transparent" CornerRadius="6" Padding="10,7">
<ContentPresenter HorizontalAlignment="Left"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Box" Property="Background" Value="{StaticResource RaisedHover}"/>
</Trigger>
<Trigger Property="IsKeyboardFocused" Value="True">
<Setter TargetName="Box" Property="Background" Value="{StaticResource RaisedHover}"/>
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter Property="Foreground" Value="{StaticResource InkFaint}"/>
<Setter Property="Cursor" Value="Arrow"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>

<Style x:Key="Choice" TargetType="ComboBox">
<Setter Property="Height" Value="40"/>
<Setter Property="FontFamily" Value="{StaticResource Ui}"/>
Expand Down
44 changes: 44 additions & 0 deletions builder/HookshotBuilder/MainWindow.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -276,7 +276,51 @@
<TextBlock x:Name="LaunchVersion" Style="{StaticResource Sub}" HorizontalAlignment="Center" TextAlignment="Center" Margin="0,8,0,0"/>
<TextBlock x:Name="LaunchStatus" FontSize="13" Foreground="{StaticResource InkSoft}" Text="Checking for updates…"
HorizontalAlignment="Center" Margin="0,16,0,0"/>
<!-- the profiles (Profiles.cs): the one Play starts, right above Play, with New profile after it. What changes the chosen
profile is wanted rarely: it is in the Manage menu, out of Play's way, with Remove last and apart -->
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center" Margin="0,28,0,0">
<TextBlock Style="{StaticResource Note}" Text="Profile" VerticalAlignment="Center" Margin="0,0,10,0"/>
<ComboBox x:Name="ProfileBox" Style="{StaticResource Choice}" Width="220" AutomationProperties.Name="Profile"
SelectionChanged="ProfileChanged"/>
<Button x:Name="ProfileManage" Style="{StaticResource Quiet}" Margin="10,0,0,0" AutomationProperties.Name="Manage profiles"
Click="ProfileManageClick">
<StackPanel Orientation="Horizontal">
<TextBlock Text="Manage" FontSize="13" FontWeight="SemiBold"/>
<Path Data="M0,0 L8,0 L4,5 Z" Fill="{StaticResource InkSoft}" VerticalAlignment="Center" Margin="8,1,0,0"/>
</StackPanel>
</Button>
<Button Style="{StaticResource Quiet}" Margin="10,0,0,0" FontSize="13" Content="New profile…" Click="ProfileNewClick"/>
</StackPanel>
<Popup x:Name="ProfileMenu" PlacementTarget="{Binding ElementName=ProfileManage}" Placement="Bottom" StaysOpen="False"
AllowsTransparency="True">
<Border Background="{StaticResource Raised}" BorderBrush="{StaticResource LineStrong}" BorderThickness="1" CornerRadius="8"
Padding="4" Margin="0,4,0,0" MinWidth="240">
<StackPanel>
<Button x:Name="MenuRename" Style="{StaticResource MenuRow}" Click="ProfileRenameClick"/>
<Button x:Name="MenuImport" Style="{StaticResource MenuRow}" Click="ProfileImportClick"/>
<Button x:Name="MenuFolder" Style="{StaticResource MenuRow}" Click="ProfileFolderClick"/>
<Border Height="1" Background="{StaticResource Line}" Margin="6,4"/>
<Button x:Name="MenuRemove" Style="{StaticResource MenuRow}" Foreground="{StaticResource Bad}" Click="ProfileRemoveClick"/>
</StackPanel>
</Border>
</Popup>
<StackPanel x:Name="ProfileNameRow" Orientation="Horizontal" HorizontalAlignment="Center" Margin="0,10,0,0" Visibility="Collapsed">
<TextBox x:Name="ProfileName" Style="{StaticResource Path}" Width="260" MaxLength="32" AutomationProperties.Name="Profile name"
KeyDown="ProfileNameKey"/>
<Button x:Name="ProfileNameOk" Style="{StaticResource Quiet}" Content="Add" Margin="10,0,0,0" Click="ProfileNameOkClick"/>
<Button Style="{StaticResource LinkButton}" Content="Cancel" Margin="14,0,0,0" VerticalAlignment="Center" Click="ProfileRowsCancel"/>
</StackPanel>
<StackPanel x:Name="SaveChoiceRow" HorizontalAlignment="Center" Margin="0,10,0,0" Visibility="Collapsed">
<TextBlock x:Name="SaveChoiceText" Style="{StaticResource Note}" HorizontalAlignment="Center" Margin="0,0,0,8"/>
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center">
<ComboBox x:Name="SaveChoice" Style="{StaticResource Choice}" Width="560" AutomationProperties.Name="Save to import"/>
<Button Style="{StaticResource Quiet}" Content="Import" Margin="10,0,0,0" Click="SaveChoiceOkClick"/>
<Button Style="{StaticResource LinkButton}" Content="Cancel" Margin="14,0,0,0" VerticalAlignment="Center" Click="ProfileRowsCancel"/>
</StackPanel>
</StackPanel>
<TextBlock x:Name="ProfileNote" Style="{StaticResource Note}" HorizontalAlignment="Center" TextAlignment="Center" MaxWidth="700"
Margin="0,10,0,0"/>
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center" Margin="0,24,0,0">
<Button x:Name="LaunchUpdate" Style="{StaticResource Primary}" Height="50" Padding="26,0" Margin="0,0,12,0" Content="Update"
Visibility="Collapsed" Click="LaunchUpdateClick"/>
<Button x:Name="LaunchPlay" Style="{StaticResource GoButton}" Click="LaunchPlayClick">
Expand Down
211 changes: 206 additions & 5 deletions builder/HookshotBuilder/MainWindow.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,9 @@ async void ShowLauncher(string folder)
var game = GameVersion(folder) ?? ReadVersion(folder);
LaunchVersion.Text = (game != null ? $"Hookshot {game}" : "Hookshot") + " · " + folder;
LaunchPlay.IsEnabled = File.Exists(GameExe(folder));
profileUser = Profiles.UserDir(folder);
ProfileNote.Text = ""; // (what the last action did; empty until then)
ShowProfiles(Profiles.Chosen(folder, SavedProfile()));
ShowPage(LaunchPage);
latestTag = Environment.GetEnvironmentVariable("HOOKSHOT_LATEST_TAG") is { Length: > 0 } fake ? (fake == "none" ? null : fake) : await Updater.LatestTag();
updateTarget = latestTag ?? Tools.Find()?.Version; // (GitHub's newest, else this builder's own)
Expand Down Expand Up @@ -233,11 +236,12 @@ static string GameExe(string folder)
return File.Exists(game) ? game : System.IO.Path.Combine(folder, "hookshot.exe");
}

/// (the launcher already checked for updates: the game does not ask again)
/// (the launcher already checked for updates: the game does not ask again. The profile is the one chosen last: Profiles.cs)
static void StartGame(string folder)
{
var si = new ProcessStartInfo(GameExe(folder)) { UseShellExecute = false, WorkingDirectory = folder };
si.Environment["HOOKSHOT_NO_UPDATE_CHECK"] = "1";
si.Environment["HOOKSHOT_PROFILE"] = Profiles.Chosen(folder, SavedProfile());
Process.Start(si);
}

Expand All @@ -252,23 +256,191 @@ void LaunchPlayClick(object sender, RoutedEventArgs e)
void LaunchFolderClick(object sender, RoutedEventArgs e) =>
Process.Start(new ProcessStartInfo(launchFolder) { UseShellExecute = true });

// ---- the launcher's profiles (Profiles.cs): which saves Play starts the game with ----
string profileUser = "";
List<Profiles.Profile> profileList = new();
string? renaming; // (the name row renames this profile; null: it adds one)
List<string> saveChoices = new();
string saveChosenFolder = "";
bool fillingProfiles;

void ShowProfiles(string id)
{
profileList = Profiles.Load(profileUser);
fillingProfiles = true;
ProfileBox.ItemsSource = profileList.Select(Profiles.ShownName).ToList();
var i = profileList.FindIndex(p => p.Id == id);
ProfileBox.SelectedIndex = i < 0 ? 0 : i;
fillingProfiles = false;
SaveProfile(CurrentProfile.Id);
FillProfileMenu();
}

/// The Manage menu, naming the profile its actions change
void FillProfileMenu()
{
var p = CurrentProfile;
MenuRename.Content = $"Rename {p.Name}…";
MenuImport.Content = $"Import a save into {p.Name}…";
MenuFolder.Content = $"Open {p.Name}'s save folder";
MenuRemove.Content = p.Id == Profiles.Default ? "The default profile cannot be removed" : $"Remove {p.Name}…";
MenuRemove.IsEnabled = p.Id != Profiles.Default;
}

// (opened, not toggled: a click on Manage while the menu is open closes it first, as any click outside does)
void ProfileManageClick(object sender, RoutedEventArgs e) => ProfileMenu.IsOpen = true;

Profiles.Profile CurrentProfile => profileList[Math.Clamp(ProfileBox.SelectedIndex, 0, profileList.Count - 1)];

void ProfileChanged(object sender, SelectionChangedEventArgs e)
{
if (fillingProfiles || profileList.Count == 0) return;
SaveProfile(CurrentProfile.Id);
FillProfileMenu();
}

void ProfileError(Exception x) => MessageBox.Show(this, x.Message, "Hookshot");

void ProfileRowsCancel(object? sender, RoutedEventArgs? e)
{
ProfileMenu.IsOpen = false;
ProfileNameRow.Visibility = Visibility.Collapsed;
SaveChoiceRow.Visibility = Visibility.Collapsed;
}

void ShowNameRow(string? id, string name, string button)
{
ProfileRowsCancel(null, null);
renaming = id;
ProfileName.Text = name;
ProfileNameOk.Content = button;
ProfileNameRow.Visibility = Visibility.Visible;
ProfileName.Focus();
ProfileName.SelectAll();
}

void ProfileNewClick(object sender, RoutedEventArgs e) => ShowNameRow(null, "", "Add");

void ProfileRenameClick(object sender, RoutedEventArgs e) => ShowNameRow(CurrentProfile.Id, CurrentProfile.Name, "Rename");

void ProfileNameKey(object sender, System.Windows.Input.KeyEventArgs e)
{
if (e.Key == System.Windows.Input.Key.Enter) ProfileNameOkClick(sender, e);
else if (e.Key == System.Windows.Input.Key.Escape) ProfileRowsCancel(sender, e);
}

void ProfileNameOkClick(object sender, RoutedEventArgs e)
{
try
{
if (renaming is { } id)
{
Profiles.Rename(profileUser, id, ProfileName.Text);
ShowProfiles(id);
ProfileNote.Text = $"Renamed to {Profiles.CleanName(ProfileName.Text)}.";
}
else
{
ShowProfiles(Profiles.Add(profileUser, ProfileName.Text));
ProfileNote.Text = $"{CurrentProfile.Name} has no saves yet: the game starts at an empty file select. Import save… brings one in.";
}
ProfileRowsCancel(null, null);
}
catch (Exception x) when (x is IOException or UnauthorizedAccessException or ArgumentException) { ProfileError(x); }
}

void ProfileRemoveClick(object sender, RoutedEventArgs e)
{
ProfileRowsCancel(null, null);
var p = CurrentProfile;
if (p.Id == Profiles.Default)
{
MessageBox.Show(this, $"{p.Name} is the default profile and cannot be removed: the game plays it when it is started without the launcher.", "Hookshot");
return;
}
var backups = System.IO.Path.Combine(profileUser, "save-backups");
if (MessageBox.Show(this, $"Remove the profile {p.Name}?\n\nIts saves are not deleted: they are moved to {backups}.", "Hookshot",
MessageBoxButton.YesNo) != MessageBoxResult.Yes) return;
try
{
var moved = Profiles.Remove(profileUser, p.Id);
ShowProfiles(Profiles.Default);
ProfileNote.Text = $"Removed {p.Name}." + (moved != null ? $" Its saves are in {moved}" : "");
}
catch (Exception x) when (x is IOException or UnauthorizedAccessException or ArgumentException) { ProfileError(x); }
}

void ProfileFolderClick(object sender, RoutedEventArgs e)
{
ProfileMenu.IsOpen = false;
if (Profiles.FolderToShow(profileUser, CurrentProfile.Id) is { } path)
Process.Start(new ProcessStartInfo("explorer.exe", $"\"{path}\"") { UseShellExecute = true });
else MessageBox.Show(this, $"There are no saves yet: the game makes {profileUser} at its first start.", "Hookshot");
}

void ProfileImportClick(object sender, RoutedEventArgs e)
{
ProfileRowsCancel(null, null);
if (PickFolder("The save to import: its folder, or an emulator's save folder") is not { } chosen) return;
var found = Profiles.FindSaves(chosen);
if (found.Count == 0)
{
MessageBox.Show(this, $"No save of Ocarina of Time in {chosen}.\n\nChoose the folder with account.sav, option.sav and slot_00 to " +
"slot_05 in it, or an emulator's save folder.", "Hookshot");
return;
}
if (found.Count == 1)
{
ImportSave(found[0], chosen);
return;
}
// (an emulator's save folder: one save for each of its users. The row asks which)
saveChoices = found;
saveChosenFolder = chosen;
SaveChoiceText.Text = $"{chosen} has {found.Count} saves of the game. Which one goes into {CurrentProfile.Name}?";
SaveChoice.ItemsSource = found.Select(f => Profiles.Describe(f, chosen)).ToList();
SaveChoice.SelectedIndex = 0;
SaveChoiceRow.Visibility = Visibility.Visible;
}

void SaveChoiceOkClick(object sender, RoutedEventArgs e)
{
var i = SaveChoice.SelectedIndex;
ProfileRowsCancel(null, null);
if (i >= 0 && i < saveChoices.Count) ImportSave(saveChoices[i], saveChosenFolder);
}

void ImportSave(string source, string chosen)
{
var p = CurrentProfile;
var backups = System.IO.Path.Combine(profileUser, "save-backups");
if (MessageBox.Show(this, $"Import this save into the profile {p.Name}?\n\n{Profiles.Describe(source, chosen)}\n\n" +
$"The profile's saves now, if it has any, are moved to {backups} first.", "Hookshot",
MessageBoxButton.YesNo) != MessageBoxResult.Yes) return;
try
{
var moved = Profiles.Import(source, profileUser, p.Id);
ProfileNote.Text = $"Imported into {p.Name}." + (moved != null ? $" Its saves before are in {moved}" : "");
}
catch (Exception x) when (x is IOException or UnauthorizedAccessException or ArgumentException) { ProfileError(x); }
}

// ---- what the player chose last time (the install folder, the game files): builder-settings.json beside the versions ----
sealed class BuilderSettings
{
public string? Out { get; set; }
public string? Nup { get; set; }
public string? Romfs { get; set; }
public string? Exefs { get; set; }
public string? Profile { get; set; } // (the launcher's profile Play starts: Profiles.cs)
public bool Dlss { get; set; } // (NVIDIA DLSS ticked: NVIDIA's license was accepted)
}
static string SettingsPath => System.IO.Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "HookshotBuilder", "builder-settings.json");

void ApplySettings()
{
BuilderSettings? s = null;
try { s = System.Text.Json.JsonSerializer.Deserialize<BuilderSettings>(File.ReadAllText(SettingsPath)); } catch (Exception) { }
if (s == null) return;
if (ReadSettings() is not { } s) return;
if (!string.IsNullOrEmpty(s.Out)) OutPath.Text = s.Out;
DlssTick.IsChecked = s.Dlss;
if (s.Romfs != null && s.Exefs != null && Directory.Exists(s.Romfs) && File.Exists(System.IO.Path.Combine(s.Exefs, "main")))
Expand All @@ -288,12 +460,34 @@ void ApplySettings()
}
}

static BuilderSettings? ReadSettings()
{
try { return System.Text.Json.JsonSerializer.Deserialize<BuilderSettings>(File.ReadAllText(SettingsPath)); }
catch (Exception) { return null; }
}

static string? SavedProfile() => ReadSettings()?.Profile;

/// (only the profile changes: the launcher has none of the build form's choices to write)
static void SaveProfile(string id)
{
try
{
var s = ReadSettings() ?? new BuilderSettings();
if (s.Profile == id) return;
s.Profile = id;
Directory.CreateDirectory(System.IO.Path.GetDirectoryName(SettingsPath)!);
File.WriteAllText(SettingsPath, System.Text.Json.JsonSerializer.Serialize(s));
}
catch (Exception) { }
}

void SaveSettings()
{
try
{
var s = new BuilderSettings { Out = OutPath.Text, Nup = romfs == null ? chosenNup : null, Romfs = romfs, Exefs = exefs,
Dlss = DlssTick.IsChecked == true };
Profile = SavedProfile(), Dlss = DlssTick.IsChecked == true };
Directory.CreateDirectory(System.IO.Path.GetDirectoryName(SettingsPath)!);
File.WriteAllText(SettingsPath, System.Text.Json.JsonSerializer.Serialize(s));
}
Expand Down Expand Up @@ -1037,8 +1231,15 @@ string Against(TimeSpan now)
void OpenFolder(object sender, RoutedEventArgs e) =>
Process.Start(new ProcessStartInfo("explorer.exe", $"\"{finished}\"") { UseShellExecute = true });

/// Play from the builder's pages: at once with one profile; with more, the launcher's page first, where the profile Play
/// starts is shown and can be changed (Profiles.cs)
void Play(object sender, RoutedEventArgs e)
{
if (Profiles.Load(Profiles.UserDir(finished)).Count > 1)
{
ShowLauncher(finished);
return;
}
StartGame(finished);
WindowState = WindowState.Minimized;
}
Expand Down
Loading