Switch the operator profile while the application is running

Splits the teardown out of stop() into a reusable, idempotent shutdownRuntime()
and adds File - Switch operator profile..., which tears the current runtime
down and builds a fresh one for the selected profile.

The new runtime is a new application instance rather than a second start() on
the existing one. Many controls are instance fields created once, so reusing
the instance would re-parent mounted nodes and register every listener twice.
A fresh instance is safe because the class keeps no mutable static state.

Closing every window during a switch would end the process under the JavaFX
default, so the application takes over the exit decision: implicit exit is
turned off, the main window gets an explicit close handler, and every exit path
runs through the launcher. That also fixes losing the window layout on exit
after a switch, because JavaFX only calls stop() on the instance it launched.

The two view timers are now cancelled null-safe; stop() used to dereference
them unguarded, which would fail if shutdown happened before they were created.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hpa6bjie5qkeNG62y6FmXm
This commit is contained in:
Claude
2026-09-07 07:50:03 +00:00
parent 11912f4492
commit 5bff7a119e
2 changed files with 315 additions and 13 deletions
@@ -0,0 +1,106 @@
package kst4contest.view;
import kst4contest.controller.ActiveOperatorProfile;
import kst4contest.controller.OperatorProfilePaths;
import kst4contest.controller.OperatorProfileStore;
import kst4contest.model.OperatorProfile;
import javafx.application.Platform;
import javafx.stage.Stage;
import java.util.logging.Level;
import java.util.logging.Logger;
/**
* Owns the lifecycle of the running application.
*
* <p>Switching the operator profile tears the current runtime down completely and builds
* a fresh one in the same process. Rebinding the existing windows is not an option: the
* user interface is built from the chat controller outwards, with several hundred
* references to the active preferences, and many controls are instance fields created
* once. A new {@link Kst4ContestApplication} instance gets fresh controls, which is safe
* here because the class keeps no mutable static state.</p>
*/
public final class ApplicationRuntimeLauncher {
private static final Logger LOGGER = Logger.getLogger(ApplicationRuntimeLauncher.class.getName());
private static Kst4ContestApplication currentRuntime;
private ApplicationRuntimeLauncher() {
// Utility class.
}
/**
* Registers the runtime that is currently live.
*
* @param runtime the running application instance
*/
public static void setCurrent(final Kst4ContestApplication runtime) {
currentRuntime = runtime;
}
/**
* Returns the runtime that is currently live.
*
* @return the running application instance, or null before the first startup
*/
public static Kst4ContestApplication getCurrent() {
return currentRuntime;
}
/**
* Shuts the application down.
*
* <p>JavaFX only calls {@code stop()} on the instance it launched itself, so an exit
* after a profile switch has to release the resources explicitly.</p>
*/
public static void exitApplication() {
if (currentRuntime != null) {
currentRuntime.shutdownRuntime();
}
Platform.exit();
System.exit(0);
}
/**
* Replaces the running runtime with one bound to another operator profile.
*
* @param targetProfile profile to activate
* @return true if the new runtime was built
*/
public static boolean switchProfile(final OperatorProfile targetProfile) {
if (targetProfile == null) {
return false;
}
new OperatorProfileStore().recordLastUsed(targetProfile.getProfileId());
if (currentRuntime != null) {
currentRuntime.shutdownRuntime();
}
ActiveOperatorProfile.set(OperatorProfilePaths.resolve(targetProfile));
Kst4ContestApplication nextRuntime = new Kst4ContestApplication();
try {
nextRuntime.start(new Stage());
} catch (Exception e) {
// The previous runtime is already gone, so there is nothing left to return to.
LOGGER.log(Level.SEVERE, "Could not start the selected operator profile", e);
Kst4ContestApplication.alertWindowEvent(
"The operator profile could not be started: " + e.getMessage()
+ "\n\nKST4Contest has to be closed.");
Platform.exit();
System.exit(1);
return false;
}
setCurrent(nextRuntime);
return true;
}
}
@@ -15,6 +15,7 @@ import java.util.logging.SimpleFormatter;
import java.util.function.Consumer; import java.util.function.Consumer;
import java.util.function.Predicate; import java.util.function.Predicate;
import javafx.animation.Animation;
import javafx.animation.KeyFrame; import javafx.animation.KeyFrame;
import javafx.animation.Timeline; import javafx.animation.Timeline;
import javafx.collections.FXCollections; import javafx.collections.FXCollections;
@@ -72,6 +73,8 @@ import kst4contest.utils.ApplicationFileUtils;
import kst4contest.view.map.StationMapBridge; import kst4contest.view.map.StationMapBridge;
import kst4contest.controller.ActiveOperatorProfile; import kst4contest.controller.ActiveOperatorProfile;
import kst4contest.controller.OperatorProfileStore; import kst4contest.controller.OperatorProfileStore;
import kst4contest.controller.OperatorProfilePaths;
import kst4contest.model.OperatorProfile;
import kst4contest.model.OperatorProfileSelection; import kst4contest.model.OperatorProfileSelection;
import kst4contest.view.map.StationMapView; import kst4contest.view.map.StationMapView;
import kst4contest.view.map.OfflineDemImportService; import kst4contest.view.map.OfflineDemImportService;
@@ -5503,6 +5506,9 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
}); });
MenuItem menuItemFileSwitchProfile = new MenuItem("Switch operator profile...");
menuItemFileSwitchProfile.setOnAction(event -> showOperatorProfileSwitchDialog());
MenuItem m10 = new MenuItem("Exit + disconnect"); MenuItem m10 = new MenuItem("Exit + disconnect");
m10.setOnAction(new EventHandler<ActionEvent>() { m10.setOnAction(new EventHandler<ActionEvent>() {
public void handle(ActionEvent event) { public void handle(ActionEvent event) {
@@ -5513,6 +5519,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
// add menu items to menu // add menu items to menu
fileMenu.getItems().add(menuItemFileConnect); fileMenu.getItems().add(menuItemFileConnect);
fileMenu.getItems().add(menuItemFileDisconnect); fileMenu.getItems().add(menuItemFileDisconnect);
fileMenu.getItems().add(menuItemFileSwitchProfile);
fileMenu.getItems().add(m10); fileMenu.getItems().add(m10);
Menu optionsMenu = new Menu("Options"); Menu optionsMenu = new Menu("Options");
@@ -6099,6 +6106,17 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
FlowPane flwPane_textSnippets; FlowPane flwPane_textSnippets;
FlowPane flwpne_StatusBar; FlowPane flwpne_StatusBar;
/**
* True once this runtime released its resources. Shutdown must stay idempotent
* because it is reached both through the JavaFX stop() callback and explicitly.
*/
private boolean runtimeShutdownDone;
/**
* The primary stage of this runtime, remembered so shutdown can close it.
*/
private Stage ownPrimaryStage;
Stage clusterAndQSOMonStage; Stage clusterAndQSOMonStage;
// Stage stage_selectedCallSignInfoStage; // Stage stage_selectedCallSignInfoStage;
ChatMember selectedCallSignInfoStageChatMember; ChatMember selectedCallSignInfoStageChatMember;
@@ -6290,29 +6308,191 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
return " - " + activeProfile.getProfile().getDisplayName(); return " - " + activeProfile.getProfile().getDisplayName();
} }
/**
* Lets the operator pick another profile and rebuilds the runtime for it.
*
* <p>Offers to create a second profile when only one exists, because the menu entry
* is the discoverable place to find the feature at all.</p>
*/
private void showOperatorProfileSwitchDialog() {
OperatorProfileStore profileStore = new OperatorProfileStore();
List<OperatorProfile> selectableProfiles = profileStore.loadProfiles();
if (selectableProfiles.size() < 2) {
Alert noProfilesYet = new Alert(AlertType.INFORMATION);
noProfilesYet.setTitle("Operator profiles");
noProfilesYet.setHeaderText("Only one operator profile is configured.");
noProfilesYet.setContentText(
"Additional profiles are created in the settings window on the "
+ "\"Profiles\" tab. Each profile keeps its own settings and layout, "
+ "and can either share the station worked database or use its own.");
noProfilesYet.showAndWait();
return;
}
OperatorProfileSelection activeProfile = ActiveOperatorProfile.get();
String activeProfileId = activeProfile == null ? null : activeProfile.getProfile().getProfileId();
Optional<OperatorProfile> chosenProfile =
OperatorProfilePickerDialog.showAndSelect(selectableProfiles, activeProfileId);
if (chosenProfile.isEmpty()) {
return;
}
if (chosenProfile.get().getProfileId().equalsIgnoreCase(activeProfileId)) {
return;
}
if (!confirmOperatorProfileSwitch(chosenProfile.get())) {
return;
}
ApplicationRuntimeLauncher.switchProfile(chosenProfile.get());
}
/**
* Asks whether the running session may be given up for a profile switch.
*
* @param targetProfile profile the operator selected
* @return true if the switch may proceed
*/
private boolean confirmOperatorProfileSwitch(OperatorProfile targetProfile) {
Alert confirmation = new Alert(AlertType.CONFIRMATION);
confirmation.setTitle("Switch operator profile");
confirmation.setHeaderText("Switch to \"" + targetProfile.getDisplayName() + "\"?");
confirmation.setContentText(
"The ON4KST connection is closed and all windows are rebuilt with the "
+ "settings and layout of the selected profile.\n\n"
+ "Unsaved settings of the current profile are lost. Window sizes, "
+ "divider and column widths are saved automatically.");
ButtonType switchButton = new ButtonType("Switch profile", ButtonBar.ButtonData.OK_DONE);
ButtonType cancelButton = new ButtonType("Cancel", ButtonBar.ButtonData.CANCEL_CLOSE);
confirmation.getButtonTypes().setAll(switchButton, cancelButton);
return confirmation.showAndWait().orElse(cancelButton) == switchButton;
}
@Override @Override
public void stop() { public void stop() {
System.out.println("[Main.java, Info:] Stage is closing, killing all resources"); shutdownRuntime();
if (layoutAutosave != null) { System.exit(0);
layoutAutosave.flushPending(); }
/**
* Releases every resource this runtime owns, without terminating the process.
*
* <p>Separated from {@link #stop()} so the same teardown can be reused when the
* operator switches to another profile and a fresh runtime is built afterwards.
* The method is idempotent and tolerates a runtime that never connected, because a
* switch may happen before the first login.</p>
*/
public void shutdownRuntime() {
if (runtimeShutdownDone) {
return;
} }
timer_buildWindowTitle.purge();
timer_buildWindowTitle.cancel(); runtimeShutdownDone = true;
System.out.println("[Main.java, Info:] Stage is closing, killing all resources");
if (layoutAutosave != null) {
// Flush before cancelling, otherwise a pending debounced write would either
// be lost or land after a profile switch.
layoutAutosave.flushPending();
layoutAutosave.cancelPending();
}
cancelViewTimer(timer_buildWindowTitle);
timer_buildWindowTitle = null;
// timer_chatMemberTableSortTimer.purge(); // timer_chatMemberTableSortTimer.purge();
// timer_chatMemberTableSortTimer.cancel(); // timer_chatMemberTableSortTimer.cancel();
timer_updatePrivatemessageTable.purge(); cancelViewTimer(timer_updatePrivatemessageTable);
timer_updatePrivatemessageTable.cancel(); timer_updatePrivatemessageTable = null;
stopAnimation(userListRefreshCoalescer);
userListRefreshCoalescer = null;
stopAnimation(skedWarnBlinkTimeline);
skedWarnBlinkTimeline = null;
stopAnimation(bandUpgradeBlinkTimeline);
bandUpgradeBlinkTimeline = null;
if (stationMapBridge != null) {
stationMapBridge.uninstall();
stationMapBridge = null;
}
if (stationMapView != null) {
stationMapView.dispose();
stationMapView = null;
}
closeOwnedStages();
try { try {
chatcontroller.disconnect("CLOSEALL"); if (chatcontroller != null) {
chatcontroller.disconnect(ApplicationConstants.DISCSTRING_DISCONNECT_AND_CLOSE);
}
} catch (Exception e) { } catch (Exception e) {
System.out.println("[Main.java, Warning:] Exception during disconnect: " + e.getMessage()); System.out.println("[Main.java, Warning:] Exception during disconnect: " + e.getMessage());
} }
}
// Platform.exit(); /**
System.exit(0); * Cancels a timer created during user interface construction.
*
* @param timerToCancel timer to cancel, may be null when startup did not get that far
*/
private static void cancelViewTimer(Timer timerToCancel) {
if (timerToCancel == null) {
return;
}
timerToCancel.purge();
timerToCancel.cancel();
}
/**
* Stops a JavaFX animation if it exists.
*
* @param animationToStop animation to stop, may be null
*/
private static void stopAnimation(Animation animationToStop) {
if (animationToStop != null) {
animationToStop.stop();
}
}
/**
* Closes every window this runtime opened, so no stale window survives a profile
* switch. The map window is closed by its own dispose method.
*/
private void closeOwnedStages() {
for (Stage ownedStage : new Stage[] {
settingsStage, clusterAndQSOMonStage, stage_updateStage, ownPrimaryStage }) {
if (ownedStage != null) {
try {
ownedStage.close();
} catch (Exception e) {
System.out.println("[Main.java, Warning:] Could not close a window: " + e.getMessage());
}
}
}
settingsStage = null;
clusterAndQSOMonStage = null;
stage_updateStage = null;
ownPrimaryStage = null;
} }
private void requestLayoutSave() { private void requestLayoutSave() {
@@ -6723,6 +6903,20 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
return; return;
} }
ownPrimaryStage = primaryStage;
/*
* A profile switch closes every window of the old runtime before the new one
* exists. With the JavaFX default that would end the process, so the application
* takes over the exit decision and closing the main window is handled explicitly.
*/
Platform.setImplicitExit(false);
primaryStage.setOnCloseRequest(closeRequest -> {
closeRequest.consume();
ApplicationRuntimeLauncher.exitApplication();
});
ApplicationRuntimeLauncher.setCurrent(this);
GuiUtils.applyApplicationIcon(primaryStage); GuiUtils.applyApplicationIcon(primaryStage);
VBox pnl_inputAndSendButtons = new VBox(); //gets the sendtext field, send button and the timeline VBox pnl_inputAndSendButtons = new VBox(); //gets the sendtext field, send button and the timeline
@@ -12261,10 +12455,12 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
if (res.get().equals(ButtonType.CANCEL)) { if (res.get().equals(ButtonType.CANCEL)) {
// event.consume(); // event.consume();
} else { } else {
System.out.println("closewindowevent: Platform.exit"); System.out.println("closewindowevent: exiting the application");
// Routed through the launcher so the runtime that is actually live
Platform.exit(); // releases its resources. After a profile switch that is no longer the
// instance JavaFX would call stop() on.
ApplicationRuntimeLauncher.exitApplication();
} }
} }
// } // }