diff --git a/build.gradle.kts b/build.gradle.kts index 51e1bbe..ad0da1d 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -6,7 +6,7 @@ plugins { } group = "com.tpuahsiew.wirc" -version = "0.1.14" +version = "0.1.15" // No project-level repositories{} block here: settings.gradle.kts sets // dependencyResolutionManagement.repositoriesMode = FAIL_ON_PROJECT_REPOS and already @@ -40,7 +40,7 @@ compose.desktop { appResourcesRootDir.set(project.file("installerResources")) targetFormats(TargetFormat.Msi, TargetFormat.Exe) packageName = "wIRC" - packageVersion = "0.1.14" + packageVersion = "0.1.15" windows { menuGroup = "wIRC" upgradeUuid = "8f14e45f-ceea-4a5e-9c3b-5b1f0e8c2b7a" diff --git a/src/main/kotlin/Main.kt b/src/main/kotlin/Main.kt index be95ddb..f3fed53 100644 --- a/src/main/kotlin/Main.kt +++ b/src/main/kotlin/Main.kt @@ -1,6 +1,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.darkColorScheme +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -18,6 +19,8 @@ import data.AliasRepository import data.BufferKey import data.MAIN_BUFFER_KEY import data.ServerRepository +import data.SessionState +import data.SessionStateRepository import data.WindowPrefsRepository import data.storageId import irc.ConnectionManager @@ -61,10 +64,34 @@ fun main() = application { var openBuffers by remember { mutableStateOf(setOf()) } var mainUnread by remember { mutableStateOf(0) } + // wIRC lives in the tray: no window is on the taskbar, and minimizing a window hides just + // that window (see TrayWindowBehavior). Hidden windows stay composed and connected, so + // nothing is lost while they're away; showing them again puts each back where it was. + // wIRC main starts hidden if it was hidden when wIRC last quit. + val sessionRepo = remember { SessionStateRepository(appRoot) } + val startSession = remember { sessionRepo.load() } + var mainHidden by remember { mutableStateOf(startSession.mainHidden) } + var hiddenBuffers by remember { mutableStateOf(setOf()) } + val anyHidden = mainHidden || openBuffers.any { it in hiddenBuffers } + val showAll: () -> Unit = { mainHidden = false; hiddenBuffers = emptySet() } + // Windows opened after this (e.g. a new PM) still show up + val hideAll: () -> Unit = { mainHidden = true; hiddenBuffers = openBuffers } + + val saveSession: () -> Unit = { + sessionRepo.saveOnce(SessionState(mainHidden, ConnectionManager.connectedIds.value)) + } + // Also saves when Windows ends the process (sign-out, shutdown) instead of the user quitting + DisposableEffect(Unit) { + val hook = Thread { saveSession() } + Runtime.getRuntime().addShutdownHook(hook) + onDispose { } + } + // Full shutdown for the "Exit wIRC" button: disconnect every server (which also writes // all scrollback to disk), then end the process. A bare /quit in a channel window only // disconnects that window's server - see ConnectionManager.disconnect. val shutdownApp: () -> Unit = { + saveSession() ConnectionManager.disconnectAll() exitApplication() } @@ -92,20 +119,15 @@ fun main() = application { val mainLineGap = ui.rememberLineGap() ui.WindowSnapping(mainWindowState, mainLineGap, osFrame = true) - // wIRC lives in the tray: no window is on the taskbar, and minimizing any window hides - // all of them (see TrayWindowBehavior). Hidden windows stay composed and connected, so - // nothing is lost while they're away; showing them again puts each back where it was. - // A window opened while hidden (e.g. a new PM) stays hidden until they're shown. - var windowsShown by remember { mutableStateOf(true) } - val hideAll: () -> Unit = { windowsShown = false } val mainTitle = if (mainUnread > 0) "wIRC - Main ($mainUnread new)" else "wIRC - Main" ui.AppTray( // The unread count shows on the tray icon's tooltip too, so new server messages // are visible even when the windows are hidden. tooltip = if (mainUnread > 0) "wIRC ($mainUnread new)" else "wIRC", - onToggle = { windowsShown = !windowsShown }, - onShow = { windowsShown = true }, + onToggle = { if (anyHidden) showAll() else hideAll() }, + onShow = showAll, + onShowMain = { mainHidden = false }, onHide = hideAll, onExit = shutdownApp ) @@ -113,13 +135,13 @@ fun main() = application { // Closing the main server-list window quits the whole app (and every channel window // with it) - the tray's Exit does the same, and also disconnects first. Window( - onCloseRequest = ::exitApplication, - visible = windowsShown, + onCloseRequest = { saveSession(); exitApplication() }, + visible = !mainHidden, title = mainTitle, icon = painterResource("icon.png"), state = mainWindowState ) { - TrayWindowBehavior(mainWindowState, hideAll) + TrayWindowBehavior(mainWindowState) { mainHidden = true } androidx.compose.runtime.CompositionLocalProvider(ui.LocalLineGap provides mainLineGap) { MaterialTheme(colorScheme = neutralDarkScheme) { Surface { @@ -127,6 +149,9 @@ fun main() = application { repo = repo, colorScheme = neutralDarkScheme, displaySettings = mainDisplaySettings, + // Started hidden: reconnect what was connected at quit, so the channel + // windows open without wIRC main having to be shown to click Connect. + autoConnectServerIds = if (startSession.mainHidden) startSession.connectedServerIds else emptySet(), onOpenChannel = { serverId, channelName -> openBuffers = openBuffers + BufferKey(serverId, channelName) }, @@ -145,10 +170,11 @@ fun main() = application { bufferKey = bufferKey, prefsRepo = prefsRepo, colorScheme = neutralDarkScheme, - visible = windowsShown, - onHideAll = hideAll, + visible = bufferKey !in hiddenBuffers, + onHide = { hiddenBuffers = hiddenBuffers + bufferKey }, onOpenBuffer = { newKey -> openBuffers = openBuffers + newKey }, - onCloseSelf = { openBuffers = openBuffers - bufferKey }, + // Also forgets it was hidden, so reopening it later shows it + onCloseSelf = { openBuffers = openBuffers - bufferKey; hiddenBuffers = hiddenBuffers - bufferKey }, // openBuffers is read when /quit runs, so every window open at that moment // gets its "Disconnected" line, even one that hasn't had a line yet. onDisconnectServer = { diff --git a/src/main/kotlin/data/SessionStateRepository.kt b/src/main/kotlin/data/SessionStateRepository.kt new file mode 100644 index 0000000..783cb60 --- /dev/null +++ b/src/main/kotlin/data/SessionStateRepository.kt @@ -0,0 +1,66 @@ +package data + +import org.json.JSONArray +import org.json.JSONObject +import java.io.File +import java.nio.file.AtomicMoveNotSupportedException +import java.nio.file.Files +import java.nio.file.StandardCopyOption + +/** + * What wIRC looked like when it last quit: whether wIRC main was hidden to the tray, and + * which servers were connected. If main was hidden, the next start keeps it hidden and + * reconnects those servers so their channel windows open by themselves (see Main.kt). + */ +data class SessionState( + val mainHidden: Boolean = false, + val connectedServerIds: Set = emptySet() +) + +/** rootDir/session_state.json, read once at startup and written once at quit. */ +class SessionStateRepository(rootDir: File) { + + private val file = File(rootDir, "session_state.json") + @Volatile private var saved = false + + fun load(): SessionState = try { + if (!file.exists()) SessionState() else { + val obj = JSONObject(file.readText()) + val ids = obj.optJSONArray("connectedServerIds") ?: JSONArray() + SessionState( + mainHidden = obj.optBoolean("mainHidden", false), + connectedServerIds = (0 until ids.length()).map { ids.getString(it) }.toSet() + ) + } + } catch (e: Exception) { + SessionState() + } + + /** + * Only the first call counts: quitting can reach this from more than one place (the + * Exit/close handlers and the JVM shutdown hook), and a later call could come after the + * servers were already disconnected. + */ + @Synchronized + fun saveOnce(state: SessionState) { + if (saved) return + saved = true + try { + val obj = JSONObject().apply { + put("mainHidden", state.mainHidden) + put("connectedServerIds", JSONArray(state.connectedServerIds.toList())) + } + file.parentFile?.mkdirs() + // Temp file and move, same as WindowPrefsRepository, so a crash can't truncate it + val tmp = File(file.parentFile, "${file.name}.tmp") + tmp.writeText(obj.toString()) + try { + Files.move(tmp.toPath(), file.toPath(), StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE) + } catch (e: AtomicMoveNotSupportedException) { + Files.move(tmp.toPath(), file.toPath(), StandardCopyOption.REPLACE_EXISTING) + } + } catch (e: Exception) { + // Next start just opens normally + } + } +} diff --git a/src/main/kotlin/ui/AppTray.kt b/src/main/kotlin/ui/AppTray.kt index 2508053..b44b225 100644 --- a/src/main/kotlin/ui/AppTray.kt +++ b/src/main/kotlin/ui/AppTray.kt @@ -27,7 +27,8 @@ import javax.imageio.ImageIO /** * wIRC's icon in the Windows notification area, there for as long as the app runs. wIRC * windows never show on the taskbar (see [hideFromTaskbar]), so this is how they come back - * after being hidden. Left click toggles all windows; right click opens Show / Hide / Exit. + * after being hidden. Left click shows every hidden window, or hides them all if none are + * hidden; right click opens Show wIRC / Show wIRC main / Hide wIRC / Exit. * Plain AWT instead of Compose's Tray, because Compose's Tray only reports a double click. */ @Composable @@ -35,11 +36,13 @@ fun AppTray( tooltip: String, onToggle: () -> Unit, onShow: () -> Unit, + onShowMain: () -> Unit, onHide: () -> Unit, onExit: () -> Unit ) { val currentToggle by rememberUpdatedState(onToggle) val currentShow by rememberUpdatedState(onShow) + val currentShowMain by rememberUpdatedState(onShowMain) val currentHide by rememberUpdatedState(onHide) val currentExit by rememberUpdatedState(onExit) @@ -49,6 +52,7 @@ fun AppTray( ?: return@remember null val menu = PopupMenu().apply { add(MenuItem("Show wIRC").apply { addActionListener { currentShow() } }) + add(MenuItem("Show wIRC main").apply { addActionListener { currentShowMain() } }) add(MenuItem("Hide wIRC").apply { addActionListener { currentHide() } }) addSeparator() add(MenuItem("Exit").apply { addActionListener { currentExit() } }) @@ -73,17 +77,18 @@ fun AppTray( /** * Called first thing inside every wIRC Window's content: * - Keeps the window off the taskbar (see [hideFromTaskbar]). - * - Turns a minimize (the title-bar button, Win+D, etc.) into hiding every wIRC window to - * the tray: with no taskbar button, a minimized window would have no way back. + * - Turns a minimize of this window into hiding just this window to the tray ([onHide]): + * with no taskbar button, a minimized window would have no way back. Win+D minimizes + * every window, so it still hides them all. */ @Composable -fun FrameWindowScope.TrayWindowBehavior(state: WindowState, onHideAll: () -> Unit) { +fun FrameWindowScope.TrayWindowBehavior(state: WindowState, onHide: () -> Unit) { remember(window) { hideFromTaskbar(window) } - val currentHideAll by rememberUpdatedState(onHideAll) + val currentHide by rememberUpdatedState(onHide) LaunchedEffect(state) { snapshotFlow { state.isMinimized }.collect { minimized -> if (minimized) { - currentHideAll() + currentHide() // Hidden before it's un-minimized, so it doesn't flash back up on the way window.isVisible = false state.isMinimized = false diff --git a/src/main/kotlin/ui/ChannelWindow.kt b/src/main/kotlin/ui/ChannelWindow.kt index 5588b27..2b672f9 100644 --- a/src/main/kotlin/ui/ChannelWindow.kt +++ b/src/main/kotlin/ui/ChannelWindow.kt @@ -47,7 +47,7 @@ fun ChannelWindow( prefsRepo: WindowPrefsRepository, colorScheme: ColorScheme, visible: Boolean, - onHideAll: () -> Unit, + onHide: () -> Unit, onOpenBuffer: (BufferKey) -> Unit, onCloseSelf: () -> Unit, onDisconnectServer: () -> Unit @@ -86,8 +86,8 @@ fun ChannelWindow( // supplies its own title bar with minimize/close/drag/resize to make up for what the // OS chrome would otherwise provide. No Surface wrapper here (M3 Surface paints an // opaque background) - ChannelScreen already paints its own alpha-aware background. - // Hidden to the tray along with every other wIRC window when [visible] is false - the - // window and its composition stay alive, so its buffer keeps filling while hidden. + // Hidden to the tray when [visible] is false - the window and its composition stay + // alive, so its buffer keeps filling while hidden. Window( onCloseRequest = onCloseSelf, visible = visible, @@ -98,11 +98,11 @@ fun ChannelWindow( transparent = true, resizable = true ) { - TrayWindowBehavior(windowState, onHideAll) + TrayWindowBehavior(windowState, onHide) CompositionLocalProvider(LocalLineGap provides lineGap) { MaterialTheme(colorScheme = colorScheme) { ChannelScreen( - onMinimize = onHideAll, + onMinimize = onHide, onCloseWindow = onCloseSelf, channelName = windowTitle, buffer = buffer, diff --git a/src/main/kotlin/ui/MainScreen.kt b/src/main/kotlin/ui/MainScreen.kt index 112c762..4bc3fa0 100644 --- a/src/main/kotlin/ui/MainScreen.kt +++ b/src/main/kotlin/ui/MainScreen.kt @@ -41,6 +41,7 @@ fun MainScreen( repo: ServerRepository, colorScheme: ColorScheme, displaySettings: WindowDisplaySettings, + autoConnectServerIds: Set, onOpenChannel: (serverId: String, channelName: String) -> Unit, onExitApp: () -> Unit, onUnreadChange: (Int) -> Unit, @@ -57,13 +58,38 @@ fun MainScreen( var openedChannels by remember { mutableStateOf(mapOf>()) } val coroutineScope = rememberCoroutineScope() + fun connectServer(server: ServerConfig) { + // Also rejoins the channels this server was in before a /quit - their + // windows are still open, and opening one that already is is a no-op. + ConnectionManager.connect(server) + if (server.autoJoinChannels.isNotEmpty()) { + // Auto-join sends JOIN correctly on connect (see IrcConnection), + // but nothing else opens a window for it like the manual + // "Join + Open" flow does - so give the server a moment to + // actually join us before opening windows for them. + coroutineScope.launch { + delay(500) + server.autoJoinChannels.forEach { ch -> + val current = openedChannels[server.id] ?: emptyList() + if (ch !in current) openedChannels = openedChannels + (server.id to (current + ch)) + onOpenChannel(server.id, ch) + } + } + } + } + + // Servers to connect at startup, as if Connect had been clicked (see Main.kt) + LaunchedEffect(Unit) { + servers.filter { it.id in autoConnectServerIds }.forEach { connectServer(it) } + } + val buffers by ConnectionManager.buffers.collectAsState() val mainMessages by remember { derivedStateOf { buffers[MAIN_BUFFER_KEY]?.messages ?: emptyList() } } val textColor = mircPalette[displaySettings.textColorMircIndex] // Unread indicator: lines that arrived in wIRC main while this window didn't have focus // (e.g. while typing in a channel window). Shown next to the "Server messages" label and, - // via onUnreadChange, in the window title / taskbar. Cleared as soon as the window is + // via onUnreadChange, in the window title and the tray tooltip. Cleared as soon as the window is // focused. Compared by timestamp rather than count so history loaded at startup never // counts as new. val windowFocused = LocalWindowInfo.current.isWindowFocused @@ -116,25 +142,7 @@ fun MainScreen( channelInput = channelInputs[server.id] ?: "#", openedChannels = openedChannels[server.id] ?: emptyList(), onChannelInputChange = { text -> channelInputs = channelInputs + (server.id to text) }, - onConnect = { - // Also rejoins the channels this server was in before a /quit - their - // windows are still open, and opening one that already is is a no-op. - ConnectionManager.connect(server) - if (server.autoJoinChannels.isNotEmpty()) { - // Auto-join sends JOIN correctly on connect (see IrcConnection), - // but nothing else opens a window for it like the manual - // "Join + Open" flow does - so give the server a moment to - // actually join us before opening windows for them. - coroutineScope.launch { - delay(500) - server.autoJoinChannels.forEach { ch -> - val current = openedChannels[server.id] ?: emptyList() - if (ch !in current) openedChannels = openedChannels + (server.id to (current + ch)) - onOpenChannel(server.id, ch) - } - } - } - }, + onConnect = { connectServer(server) }, onEdit = { editingServer = server; showAddDialog = true }, onDelete = { repo.remove(server.id)