Improve release scripts and add git app integration (#208)

This commit is contained in:
Peter Steinberger 2025-07-03 12:40:09 +01:00 committed by GitHub
parent 74a364d1ba
commit 45d8f97a30
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 1338 additions and 302 deletions

View file

@ -1,5 +1,52 @@
# Changelog
## [1.0.0-beta.7] - 2025-07-03
### ✨ New Features
#### **Git App Integration** 🆕
- **Preferred Git App Selection** - Choose your favorite Git app (Tower, Fork, GitHub Desktop, SourceTree, Sublime Merge, GitUp, Visual Studio Code, Cursor, or Windsurf) in Settings
- **Quick Repository Access** - Open any session's working directory in your preferred Git app directly from the context menu
- **Smart Auto-Detection** - VibeTunnel automatically detects installed Git apps and pre-selects the best one based on popularity
- **Unified Apps Section** - Terminal and Git app preferences are now organized in a single, clean "Apps" section in Settings
#### **Session Name Management**
- **Server-Side Unique Names** - Session names are now automatically made unique by the server, preventing duplicate names that could cause tab selection issues
- **Smart Suffixes** - When renaming a session to an existing name, the server automatically adds a suffix like "(2)" to ensure uniqueness
- **Consistent Experience** - Both web and native clients respect server-assigned names for a unified experience
### 🎨 Improvements
#### **UI Polish**
- **Enhanced Menu Design** - Improved visual hierarchy with better spacing, subtle background tints, and refined hover effects
- **Git Status Display** - Git branch and change indicators now have better alignment and visual clarity in session rows
- **Path Display** - Working directory paths are now more readable with improved truncation and clickable folder icons
- **Settings Organization** - Cleaner settings layout with constants for maintainable code
#### **Menu Bar Enhancements**
- **Server Status Clarity** - Menu bar icon opacity now only indicates server running status, not session count
- **Expanded Menu Height** - Increased maximum menu height from 400 to 600 pixels for better visibility with many sessions
- **Consistent Context Menus** - Unified context menu design across all UI elements
### 🛠️ Technical Improvements
- **Code Quality** - Removed legacy Tower 2 support for cleaner, more maintainable code
- **Build System** - Enhanced release scripts with better error handling and recovery mechanisms
- **Type Safety** - Fixed TypeScript and Swift type issues for more robust code
- **Formatting** - Applied consistent code formatting with Biome for better readability
### 🐛 Bug Fixes
- Fixed Test button sizing to match Terminal picker height for visual consistency
- Removed invalid "System Default" option from Git app picker (no system default exists for Git apps)
- Fixed CI pipeline issues with proper code formatting
- Improved window detection logic for more reliable session tracking
### 📦 Dependencies
- Updated to version 1.0.0-beta.7 across all components
- Maintained compatibility with macOS 14.0+
## [1.0.0-beta.6] - 2025-07-03
### ✨ New Features

View file

@ -128,6 +128,27 @@ final class WindowFocuser {
}
}
/// Get the first tab group in a window (improved approach based on screenshot)
private func getTabGroup(from window: AXUIElement) -> AXUIElement? {
var childrenRef: CFTypeRef?
guard AXUIElementCopyAttributeValue(
window,
kAXChildrenAttribute as CFString,
&childrenRef
) == .success,
let children = childrenRef as? [AXUIElement]
else {
return nil
}
// Find the first element with role kAXTabGroupRole
return children.first { elem in
var roleRef: CFTypeRef?
AXUIElementCopyAttributeValue(elem, kAXRoleAttribute as CFString, &roleRef)
return (roleRef as? String) == kAXTabGroupRole as String
}
}
/// Select the correct tab in a window that uses macOS standard tabs
private func selectTab(
tabs: [AXUIElement],
@ -138,18 +159,28 @@ final class WindowFocuser {
// Try to find the correct tab
if let matchingTab = windowMatcher.findMatchingTab(tabs: tabs, sessionInfo: sessionInfo) {
// Found matching tab - select it
// Found matching tab - select it using kAXPressAction (most reliable)
let result = AXUIElementPerformAction(matchingTab, kAXPressAction as CFString)
if result == .success {
logger.info("Successfully selected matching tab for session \(windowInfo.sessionID)")
} else {
logger.warning("Failed to select tab, error: \(result.rawValue)")
logger.warning("Failed to select tab with kAXPressAction, error: \(result.rawValue)")
// Try alternative selection method - set as selected
var selectedValue: CFTypeRef?
if AXUIElementCopyAttributeValue(matchingTab, kAXSelectedAttribute as CFString, &selectedValue) == .success {
AXUIElementSetAttributeValue(matchingTab, kAXSelectedAttribute as CFString, true as CFTypeRef)
if AXUIElementCopyAttributeValue(matchingTab, kAXSelectedAttribute as CFString, &selectedValue) ==
.success
{
let setResult = AXUIElementSetAttributeValue(
matchingTab,
kAXSelectedAttribute as CFString,
true as CFTypeRef
)
if setResult == .success {
logger.info("Selected tab using AXSelected attribute")
} else {
logger.error("Failed to set AXSelected attribute, error: \(setResult.rawValue)")
}
}
}
} else if tabs.count == 1 {
@ -158,19 +189,42 @@ final class WindowFocuser {
logger.info("Selected the only available tab")
} else {
// Multiple tabs but no match - try to find by index or select first
logger.warning("Multiple tabs (\(tabs.count)) but could not identify correct one for session \(windowInfo.sessionID)")
logger
.warning(
"Multiple tabs (\(tabs.count)) but could not identify correct one for session \(windowInfo.sessionID)"
)
// Log tab titles for debugging
for (index, tab) in tabs.enumerated() {
var titleValue: CFTypeRef?
if AXUIElementCopyAttributeValue(tab, kAXTitleAttribute as CFString, &titleValue) == .success,
let title = titleValue as? String {
let title = titleValue as? String
{
logger.debug(" Tab \(index): \(title)")
}
}
}
}
/// Select a tab by index in a tab group (helper method from screenshot)
private func selectTab(at index: Int, in group: AXUIElement) -> Bool {
var tabsRef: CFTypeRef?
guard AXUIElementCopyAttributeValue(
group,
"AXTabs" as CFString,
&tabsRef
) == .success,
let tabs = tabsRef as? [AXUIElement],
index < tabs.count
else {
logger.warning("Could not get tabs from group or index out of bounds")
return false
}
let result = AXUIElementPerformAction(tabs[index], kAXPressAction as CFString)
return result == .success
}
/// Focuses a window using Accessibility APIs.
private func focusWindowUsingAccessibility(_ windowInfo: WindowEnumerator.WindowInfo) {
// First bring the application to front
@ -214,7 +268,34 @@ final class WindowFocuser {
logger.debug("Window \(index) _AXWindowNumber: \(axWindowID), matches: \(windowMatches)")
}
// Check if this window has tabs
// Try the improved approach: get tab group first
if let tabGroup = getTabGroup(from: window) {
// Get tabs from the tab group
var tabsValue: CFTypeRef?
if AXUIElementCopyAttributeValue(tabGroup, "AXTabs" as CFString, &tabsValue) == .success,
let tabs = tabsValue as? [AXUIElement],
!tabs.isEmpty
{
logger.info("Window \(index) has tab group with \(tabs.count) tabs")
// Try to find matching tab
if windowMatcher.findMatchingTab(tabs: tabs, sessionInfo: sessionInfo) != nil {
// Found the tab! Focus the window and select the tab
logger.info("Found matching tab in window \(index)")
// Make window main and focused
AXUIElementSetAttributeValue(window, kAXMainAttribute as CFString, true as CFTypeRef)
AXUIElementSetAttributeValue(window, kAXFocusedAttribute as CFString, true as CFTypeRef)
// Select the tab
selectTab(tabs: tabs, windowInfo: windowInfo, sessionInfo: sessionInfo)
foundWindowWithTab = true
return
}
}
} else {
// Fallback: Try direct tabs attribute (older approach)
var tabsValue: CFTypeRef?
let hasTabsResult = AXUIElementCopyAttributeValue(window, kAXTabsAttribute as CFString, &tabsValue)
@ -222,7 +303,7 @@ final class WindowFocuser {
let tabs = tabsValue as? [AXUIElement],
!tabs.isEmpty
{
logger.info("Window \(index) has \(tabs.count) tabs")
logger.info("Window \(index) has \(tabs.count) tabs (direct attribute)")
// Try to find matching tab
if windowMatcher.findMatchingTab(tabs: tabs, sessionInfo: sessionInfo) != nil {
@ -251,6 +332,7 @@ final class WindowFocuser {
return
}
}
}
// If we didn't find a window with matching tab, just focus the first window
if !foundWindowWithTab && !windows.isEmpty {

View file

@ -200,7 +200,10 @@ final class WindowMatcher {
logger.warning("Could not find window for session \(sessionID) after all attempts")
logger.debug("Available windows: \(allWindows.count)")
for (index, window) in allWindows.enumerated() {
logger.debug(" Window \(index): PID=\(window.ownerPID), Terminal=\(window.terminalApp.rawValue), Title=\(window.title ?? "<no title>")")
logger
.debug(
" Window \(index): PID=\(window.ownerPID), Terminal=\(window.terminalApp.rawValue), Title=\(window.title ?? "<no title>")"
)
}
return nil

View file

@ -435,6 +435,11 @@ struct CustomMenuContainer<Content: View>: View {
var body: some View {
content
.fixedSize()
.background {
// First layer: tinted background for better readability
SideRoundedRectangle(cornerRadius: DesignConstants.menuCornerRadius)
.fill(backgroundTint)
}
.background(backgroundMaterial, in: SideRoundedRectangle(cornerRadius: DesignConstants.menuCornerRadius))
.overlay(
SideRoundedRectangle(cornerRadius: DesignConstants.menuCornerRadius)
@ -442,6 +447,19 @@ struct CustomMenuContainer<Content: View>: View {
)
}
private var backgroundTint: Color {
switch colorScheme {
case .dark:
// Black tint at 25% opacity for better text readability
Color.black.opacity(0.25)
case .light:
// White tint at 45% opacity for better contrast
Color.white.opacity(0.45)
@unknown default:
Color.black.opacity(0.25)
}
}
private var borderColor: Color {
switch colorScheme {
case .dark:

View file

@ -10,8 +10,19 @@ struct GitRepositoryRow: View {
@Environment(\.colorScheme)
private var colorScheme
private var gitAppName: String {
if let preferredApp = UserDefaults.standard.string(forKey: "preferredGitApp"),
!preferredApp.isEmpty,
let gitApp = GitApp(rawValue: preferredApp)
{
return gitApp.displayName
}
// Return first installed git app or default
return GitApp.installed.first?.displayName ?? "Git App"
}
private var branchInfo: some View {
HStack(spacing: 2) {
HStack(spacing: 1) {
Image(systemName: "arrow.branch")
.font(.system(size: 9))
.foregroundColor(AppColors.Fallback.gitBranch(for: colorScheme))
@ -21,31 +32,49 @@ struct GitRepositoryRow: View {
.foregroundColor(AppColors.Fallback.gitBranch(for: colorScheme))
.lineLimit(1)
.truncationMode(.middle)
.frame(maxWidth: 60)
}
}
private var changeIndicators: some View {
Group {
if repository.hasChanges {
HStack(spacing: 2) {
HStack(spacing: 3) {
if repository.modifiedCount > 0 {
Text("M:\(repository.modifiedCount)")
HStack(spacing: 1) {
Image(systemName: "arrow.trianglehead.2.clockwise.rotate.90")
.font(.system(size: 8))
.foregroundColor(AppColors.Fallback.gitModified(for: colorScheme))
Text("\(repository.modifiedCount)")
.font(.system(size: 9, weight: .medium))
.foregroundColor(AppColors.Fallback.gitModified(for: colorScheme))
}
}
if repository.addedCount > 0 {
Text("A:\(repository.addedCount)")
HStack(spacing: 1) {
Image(systemName: "plus")
.font(.system(size: 8, weight: .medium))
.foregroundColor(AppColors.Fallback.gitAdded(for: colorScheme))
Text("\(repository.addedCount)")
.font(.system(size: 9, weight: .medium))
.foregroundColor(AppColors.Fallback.gitAdded(for: colorScheme))
}
}
if repository.deletedCount > 0 {
Text("D:\(repository.deletedCount)")
HStack(spacing: 1) {
Image(systemName: "minus")
.font(.system(size: 8, weight: .medium))
.foregroundColor(AppColors.Fallback.gitDeleted(for: colorScheme))
Text("\(repository.deletedCount)")
.font(.system(size: 9, weight: .medium))
.foregroundColor(AppColors.Fallback.gitDeleted(for: colorScheme))
}
}
if repository.untrackedCount > 0 {
Text("U:\(repository.untrackedCount)")
HStack(spacing: 1) {
Image(systemName: "questionmark")
.font(.system(size: 8))
.foregroundColor(AppColors.Fallback.gitUntracked(for: colorScheme))
Text("\(repository.untrackedCount)")
.font(.system(size: 9, weight: .medium))
.foregroundColor(AppColors.Fallback.gitUntracked(for: colorScheme))
}
@ -53,6 +82,7 @@ struct GitRepositoryRow: View {
}
}
}
}
private var backgroundView: some View {
RoundedRectangle(cornerRadius: 4)
@ -60,8 +90,13 @@ struct GitRepositoryRow: View {
}
private var backgroundFillColor: Color {
// Only show background on hover - very subtle
isHovering ? AppColors.Fallback.controlBackground(for: colorScheme).opacity(0.15) : Color.clear
// Show background on hover - stronger in light mode
if isHovering {
return colorScheme == .light
? AppColors.Fallback.controlBackground(for: colorScheme).opacity(0.25)
: AppColors.Fallback.controlBackground(for: colorScheme).opacity(0.15)
}
return Color.clear
}
private var borderView: some View {
@ -70,23 +105,31 @@ struct GitRepositoryRow: View {
}
private var borderColor: Color {
// Only show border on hover
isHovering ? AppColors.Fallback.gitBorder(for: colorScheme).opacity(0.2) : Color.clear
// Show border on hover - stronger in light mode
if isHovering {
return colorScheme == .light
? AppColors.Fallback.gitBorder(for: colorScheme).opacity(0.3)
: AppColors.Fallback.gitBorder(for: colorScheme).opacity(0.2)
}
return Color.clear
}
var body: some View {
HStack(spacing: 3) {
HStack(spacing: 2) {
// Branch info
branchInfo
if repository.hasChanges {
Text("")
.font(.system(size: 8))
.foregroundColor(.secondary.opacity(0.5))
}
changeIndicators
}
.padding(.horizontal, 6)
Spacer()
}
.padding(.horizontal, 4)
.padding(.vertical, 2)
.background(backgroundView)
.overlay(borderView)
@ -96,63 +139,11 @@ struct GitRepositoryRow: View {
.onTapGesture {
openInGitApp()
}
.help("Open in Git app")
.contextMenu {
Button("Open in Tower") {
openInGitApp()
}
Button("Open Repository in Finder") {
NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: repository.path)
}
if repository.githubURL != nil {
Button("Open on GitHub") {
if let url = repository.githubURL {
NSWorkspace.shared.open(url)
}
}
}
Divider()
Button("Copy Branch Name") {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(repository.currentBranch ?? "detached", forType: .string)
}
Button("Copy Repository Path") {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(repository.path, forType: .string)
}
}
.help("Open in \(gitAppName)")
.animation(.easeInOut(duration: 0.15), value: isHovering)
}
private func openInGitApp() {
// Try to open in Tower first, fall back to SourceTree, then GitKraken
let gitApps = [
"com.fournova.Tower3",
"com.fournova.Tower2",
"com.torusknot.SourceTreeNotMAS",
"com.axosoft.gitkraken"
]
let url = URL(fileURLWithPath: repository.path)
// Try each app in order
for appIdentifier in gitApps {
if let appURL = NSWorkspace.shared.urlForApplication(withBundleIdentifier: appIdentifier) {
NSWorkspace.shared.open(
[url],
withApplicationAt: appURL,
configuration: NSWorkspace.OpenConfiguration()
)
return
}
}
// If no git app found, open in Finder as fallback
NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: repository.path)
GitAppLauncher.shared.openRepository(at: repository.path)
}
}

View file

@ -30,7 +30,7 @@ struct MenuActionBar: View {
.background(
RoundedRectangle(cornerRadius: 8)
.fill(isHoveringNewSession ? AppColors.Fallback.controlBackground(for: colorScheme)
.opacity(0.15) : Color.clear
.opacity(colorScheme == .light ? 0.25 : 0.15) : Color.clear
)
.scaleEffect(isHoveringNewSession ? 1.1 : 1.0)
.animation(.easeInOut(duration: 0.15), value: isHoveringNewSession)
@ -62,7 +62,7 @@ struct MenuActionBar: View {
.background(
RoundedRectangle(cornerRadius: 8)
.fill(isHoveringSettings ? AppColors.Fallback.controlBackground(for: colorScheme)
.opacity(0.15) : Color.clear
.opacity(colorScheme == .light ? 0.25 : 0.15) : Color.clear
)
.scaleEffect(isHoveringSettings ? 1.1 : 1.0)
.animation(.easeInOut(duration: 0.15), value: isHoveringSettings)
@ -96,7 +96,7 @@ struct MenuActionBar: View {
.background(
RoundedRectangle(cornerRadius: 8)
.fill(isHoveringQuit ? AppColors.Fallback.controlBackground(for: colorScheme)
.opacity(0.15) : Color.clear
.opacity(colorScheme == .light ? 0.25 : 0.15) : Color.clear
)
.scaleEffect(isHoveringQuit ? 1.1 : 1.0)
.animation(.easeInOut(duration: 0.15), value: isHoveringQuit)

View file

@ -102,6 +102,7 @@ struct ServerAddressRow: View {
Image(systemName: icon)
.font(.system(size: 10))
.foregroundColor(AppColors.Fallback.serverRunning(for: colorScheme))
.frame(width: 14, alignment: .center)
Text(label)
.font(.system(size: 11))
.foregroundColor(.secondary)

View file

@ -27,25 +27,24 @@ struct SessionRow: View {
@State private var isTerminating = false
@State private var isEditing = false
@State private var editedName = ""
@State private var gitRepository: GitRepository?
@State private var isHoveringFolder = false
@FocusState private var isEditFieldFocused: Bool
// Computed property that reads directly from the monitor's cache
// This will automatically update when the monitor refreshes
private var gitRepository: GitRepository? {
gitRepositoryMonitor.getCachedRepository(for: session.value.workingDir)
}
var body: some View {
Button(action: handleTap) {
content
}
.buttonStyle(PlainButtonStyle())
.onAppear {
// First, try to get cached data synchronously
if gitRepository == nil {
gitRepository = gitRepositoryMonitor.getCachedRepository(for: session.value.workingDir)
}
}
.task(id: session.value.workingDir) {
// Then fetch fresh data asynchronously (this will update the cache)
if let freshData = await gitRepositoryMonitor.findRepository(for: session.value.workingDir) {
gitRepository = freshData
// Fetch repository data if not already cached
if gitRepository == nil {
_ = await gitRepositoryMonitor.findRepository(for: session.value.workingDir)
}
}
}
@ -125,7 +124,7 @@ struct SessionRow: View {
// Second row: Path, Git info, Duration and X button
HStack(alignment: .center, spacing: 6) {
// Left side: Path and git info
HStack(alignment: .center, spacing: 6) {
HStack(alignment: .center, spacing: 4) {
// Folder icon and path - clickable as one unit
Button(action: {
NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: session.value.workingDir)
@ -139,7 +138,7 @@ struct SessionRow: View {
.font(.system(size: 10, design: .monospaced))
.foregroundColor(.secondary)
.lineLimit(1)
.truncationMode(.middle)
.truncationMode(.head)
}
.padding(.horizontal, 6)
.padding(.vertical, 2)
@ -249,14 +248,20 @@ struct SessionRow: View {
openWindow(id: "session-detail", value: session.key)
}
Divider()
Button("Show in Finder") {
NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: session.value.workingDir)
}
// Add git repository options if available
if let repo = gitRepository {
Button("Open Git Repository in Finder") {
NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: repo.path)
Divider()
// Open in Git app
let gitAppName = getGitAppName()
Button("Open in \(gitAppName)") {
GitAppLauncher.shared.openRepository(at: repo.path)
}
if repo.githubURL != nil {
@ -266,8 +271,22 @@ struct SessionRow: View {
}
}
}
Divider()
Button("Copy Branch Name") {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(repo.currentBranch ?? "detached", forType: .string)
}
Button("Copy Repository Path") {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(repo.path, forType: .string)
}
}
Divider()
Button("Rename Session...") {
startEditing()
}
@ -300,6 +319,16 @@ struct SessionRow: View {
}
}
private func getGitAppName() -> String {
if let preferredApp = UserDefaults.standard.string(forKey: "preferredGitApp"),
!preferredApp.isEmpty,
let gitApp = GitApp(rawValue: preferredApp) {
return gitApp.displayName
}
// Return first installed git app or default
return GitApp.installed.first?.displayName ?? "Git App"
}
private func terminateSession() {
isTerminating = true

View file

@ -136,8 +136,8 @@ final class StatusBarController: NSObject {
func updateStatusItemDisplay() {
guard let button = statusItem?.button else { return }
// Update icon based on server and network status
let iconName = (serverManager.isRunning && hasNetworkAccess) ? "menubar" : "menubar.inactive"
// Update icon based on server status only
let iconName = serverManager.isRunning ? "menubar" : "menubar.inactive"
if let image = NSImage(named: iconName) {
image.isTemplate = true
button.image = image
@ -146,7 +146,7 @@ final class StatusBarController: NSObject {
if let image = NSImage(named: "menubar") {
image.isTemplate = true
button.image = image
button.alphaValue = (serverManager.isRunning && hasNetworkAccess) ? 1.0 : 0.5
button.alphaValue = serverManager.isRunning ? 1.0 : 0.5
}
}

View file

@ -148,10 +148,18 @@ final class StatusBarMenuManager: NSObject {
customWindow = nil
customWindow = CustomMenuWindow(contentView: containerView)
// Set up callback to reset state when window hides
// Set up callbacks for window show/hide
customWindow?.onShow = { [weak self] in
// Start monitoring git repositories for updates every 5 seconds
self?.gitRepositoryMonitor?.startMonitoring()
}
customWindow?.onHide = { [weak self] in
self?.statusBarButton?.highlight(false)
// Stop monitoring git repositories when menu closes
self?.gitRepositoryMonitor?.stopMonitoring()
// Ensure state is reset on main thread
Task { @MainActor in
self?.updateMenuState(.none)

View file

@ -92,7 +92,7 @@ struct VibeTunnelMenuView: View {
}
)
}
.frame(maxHeight: 400)
.frame(maxHeight: 600)
Divider()

View file

@ -1,5 +1,7 @@
import os
import SwiftUI
@preconcurrency import ScreenCaptureKit
import ApplicationServices
/// View displaying detailed information about a specific terminal session.
///
@ -9,10 +11,18 @@ import SwiftUI
struct SessionDetailView: View {
let session: ServerSessionInfo
@State private var windowTitle = ""
@State private var windowInfo: WindowEnumerator.WindowInfo?
@State private var windowScreenshot: NSImage?
@State private var isCapturingScreenshot = false
@State private var hasScreenCapturePermission = false
@State private var isFindingWindow = false
@State private var windowSearchAttempted = false
private let logger = Logger(subsystem: "sh.vibetunnel.vibetunnel", category: "SessionDetailView")
var body: some View {
HStack(spacing: 30) {
// Left side: Session Information
VStack(alignment: .leading, spacing: 20) {
// Session Header
VStack(alignment: .leading, spacing: 8) {
@ -74,10 +84,119 @@ struct SessionDetailView: View {
}
}
}
.frame(minWidth: 400)
Divider()
// Right side: Window Information and Screenshot
VStack(alignment: .leading, spacing: 20) {
Text("Window Information")
.font(.title2)
.fontWeight(.semibold)
if let windowInfo = windowInfo {
VStack(alignment: .leading, spacing: 12) {
DetailRow(label: "Window ID", value: "\(windowInfo.windowID)")
DetailRow(label: "Terminal App", value: windowInfo.terminalApp.displayName)
DetailRow(label: "Owner PID", value: "\(windowInfo.ownerPID)")
if let bounds = windowInfo.bounds {
DetailRow(label: "Position", value: "X: \(Int(bounds.origin.x)), Y: \(Int(bounds.origin.y))")
DetailRow(label: "Size", value: "\(Int(bounds.width)) × \(Int(bounds.height))")
}
if let title = windowInfo.title {
DetailRow(label: "Window Title", value: title)
}
HStack {
Button("Focus Window") {
focusWindow()
}
.controlSize(.regular)
Button("Capture Screenshot") {
Task {
await captureWindowScreenshot()
}
}
.controlSize(.regular)
.disabled(isCapturingScreenshot)
}
}
// Window Screenshot
if let screenshot = windowScreenshot {
VStack(alignment: .leading, spacing: 8) {
Text("Window Preview")
.font(.headline)
.foregroundColor(.secondary)
Image(nsImage: screenshot)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(maxWidth: 400, maxHeight: 300)
.background(Color.gray.opacity(0.1))
.cornerRadius(8)
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(Color.gray.opacity(0.3), lineWidth: 1)
)
}
} else if !hasScreenCapturePermission {
VStack(alignment: .leading, spacing: 12) {
Text("Screen Recording Permission Required")
.font(.headline)
.foregroundColor(.orange)
Text("VibeTunnel needs Screen Recording permission to capture window screenshots.")
.font(.caption)
.foregroundColor(.secondary)
.fixedSize(horizontal: false, vertical: true)
Button("Open System Settings") {
openScreenRecordingSettings()
}
.controlSize(.small)
}
.padding()
.background(Color.orange.opacity(0.1))
.cornerRadius(8)
}
} else {
VStack(alignment: .leading, spacing: 12) {
if windowSearchAttempted {
Label("No window found", systemImage: "exclamationmark.triangle")
.foregroundColor(.orange)
.font(.headline)
Text("Could not find a terminal window for this session. The window may have been closed or the session was started outside VibeTunnel.")
.foregroundColor(.secondary)
.font(.caption)
.fixedSize(horizontal: false, vertical: true)
} else {
Text("No window information available")
.foregroundColor(.secondary)
}
Button(isFindingWindow ? "Searching..." : "Find Window") {
findWindow()
}
.controlSize(.regular)
.disabled(isFindingWindow)
}
.padding(.vertical, 20)
}
Spacer()
}
.frame(minWidth: 400)
}
.padding(30)
.frame(minWidth: 600, minHeight: 450)
.frame(minWidth: 900, minHeight: 450)
.onAppear {
updateWindowTitle()
findWindow()
}
.background(WindowAccessor(title: $windowTitle))
}
@ -114,6 +233,188 @@ struct SessionDetailView: View {
// TODO: Implement session termination
logger.info("Terminating session \(session.id)")
}
private func findWindow() {
isFindingWindow = true
windowSearchAttempted = true
Task { @MainActor in
defer {
isFindingWindow = false
}
logger.info("Looking for window associated with session \(session.id)")
// First, check if WindowTracker already has window info for this session
if let trackedWindow = WindowTracker.shared.windowInfo(for: session.id) {
logger.info("Found tracked window for session \(session.id): windowID=\(trackedWindow.windowID), terminal=\(trackedWindow.terminalApp.rawValue)")
self.windowInfo = trackedWindow
return
}
logger.info("No tracked window found for session \(session.id), attempting to find it...")
// Get all terminal windows for debugging
let allWindows = WindowEnumerator.getAllTerminalWindows()
logger.info("Found \(allWindows.count) terminal windows currently open")
// Log details about each window for debugging
for (index, window) in allWindows.enumerated() {
logger.debug("Window \(index): terminal=\(window.terminalApp.rawValue), windowID=\(window.windowID), ownerPID=\(window.ownerPID), title=\(window.title ?? "<no title>")")
}
// Log session details for debugging
logger.info("Session details: id=\(session.id), pid=\(session.pid ?? -1), workingDir=\(session.workingDir), attachedViaVT=\(session.attachedViaVT ?? false)")
// Try to match by various criteria
if let pid = session.pid {
logger.info("Looking for window with PID \(pid)...")
if let window = allWindows.first(where: { $0.ownerPID == pid }) {
logger.info("Found window by PID match: windowID=\(window.windowID)")
self.windowInfo = window
// Register this window with WindowTracker for future use
WindowTracker.shared.registerWindow(
for: session.id,
terminalApp: window.terminalApp,
tabReference: nil,
tabID: nil
)
return
} else {
logger.warning("No window found with PID \(pid)")
}
}
// Try to find by window title containing working directory
let workingDirName = URL(fileURLWithPath: session.workingDir).lastPathComponent
logger.info("Looking for window with title containing '\(workingDirName)'...")
if let window = allWindows.first(where: { window in
if let title = window.title {
return title.contains(workingDirName) || title.contains(session.id)
}
return false
}) {
logger.info("Found window by title match: windowID=\(window.windowID), title=\(window.title ?? "")")
self.windowInfo = window
// Register this window with WindowTracker for future use
WindowTracker.shared.registerWindow(
for: session.id,
terminalApp: window.terminalApp,
tabReference: nil,
tabID: nil
)
return
}
logger.warning("Could not find window for session \(session.id) after checking all \(allWindows.count) terminal windows")
logger.warning("Session may not have an associated terminal window or window detection failed")
}
}
private func focusWindow() {
// Use WindowTracker's existing focus logic which handles all the complexity
logger.info("Attempting to focus window for session \(session.id)")
// First ensure we have window info
if windowInfo == nil {
logger.info("No window info cached, trying to find window first...")
findWindow()
}
if let windowInfo = windowInfo {
logger.info("Using WindowTracker to focus window: windowID=\(windowInfo.windowID), terminal=\(windowInfo.terminalApp.rawValue)")
WindowTracker.shared.focusWindow(for: session.id)
} else {
logger.error("Cannot focus window - no window found for session \(session.id)")
}
}
private func captureWindowScreenshot() async {
guard let windowInfo = windowInfo else {
logger.warning("No window info available for screenshot")
return
}
await MainActor.run {
isCapturingScreenshot = true
}
defer {
Task { @MainActor in
isCapturingScreenshot = false
}
}
// Check for screen recording permission
let hasPermission = await checkScreenCapturePermission()
await MainActor.run {
hasScreenCapturePermission = hasPermission
}
guard hasPermission else {
logger.warning("No screen capture permission")
return
}
do {
// Get available content
let availableContent = try await SCShareableContent.current
// Find the window
guard let window = availableContent.windows.first(where: { $0.windowID == windowInfo.windowID }) else {
logger.warning("Window not found in shareable content")
return
}
// Create content filter for this specific window
let filter = SCContentFilter(desktopIndependentWindow: window)
// Configure the capture
let config = SCStreamConfiguration()
config.width = Int(window.frame.width * 2) // Retina resolution
config.height = Int(window.frame.height * 2)
config.scalesToFit = true
config.showsCursor = false
config.captureResolution = .best
// Capture the screenshot
let screenshot = try await SCScreenshotManager.captureImage(
contentFilter: filter,
configuration: config
)
// Convert CGImage to NSImage
let nsImage = NSImage(cgImage: screenshot, size: NSSize(width: screenshot.width, height: screenshot.height))
await MainActor.run {
self.windowScreenshot = nsImage
}
logger.info("Successfully captured window screenshot")
} catch {
logger.error("Failed to capture screenshot: \(error)")
}
}
private func checkScreenCapturePermission() async -> Bool {
// Check if we have screen recording permission
let hasPermission = CGPreflightScreenCaptureAccess()
if !hasPermission {
// Request permission
return CGRequestScreenCaptureAccess()
}
return true
}
private func openScreenRecordingSettings() {
if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture") {
NSWorkspace.shared.open(url)
}
}
}
// MARK: - Supporting Views

View file

@ -22,7 +22,7 @@ struct AdvancedSettingsView: View {
var body: some View {
NavigationStack {
Form {
// Terminal preference section
// Apps preference section
TerminalPreferenceSection()
// Integration section
@ -201,40 +201,22 @@ struct AdvancedSettingsView: View {
private struct TerminalPreferenceSection: View {
@AppStorage("preferredTerminal")
private var preferredTerminal = Terminal.terminal.rawValue
@AppStorage("preferredGitApp")
private var preferredGitApp = ""
@State private var terminalLauncher = TerminalLauncher.shared
@State private var gitAppLauncher = GitAppLauncher.shared
@State private var showingError = false
@State private var errorMessage = ""
@State private var errorTitle = "Terminal Launch Failed"
var body: some View {
Section {
VStack(alignment: .leading, spacing: 8) {
VStack(alignment: .leading, spacing: 12) {
// Terminal selector row
HStack {
Text("Preferred Terminal")
Spacer()
Picker("", selection: $preferredTerminal) {
ForEach(Terminal.installed, id: \.rawValue) { terminal in
HStack {
if let icon = terminal.appIcon {
Image(nsImage: icon.resized(to: NSSize(width: 16, height: 16)))
}
Text(terminal.displayName)
}
.tag(terminal.rawValue)
}
}
.pickerStyle(.menu)
.labelsHidden()
}
Text("Select which application to use when creating new sessions")
.font(.caption)
.foregroundStyle(.secondary)
// Test button
HStack {
Text("Test Terminal")
Spacer()
Button("Test Open Terminal") {
Button("Test") {
Task {
do {
try terminalLauncher.launchCommand("echo 'VibeTunnel Terminal Test: Success!'")
@ -297,18 +279,49 @@ private struct TerminalPreferenceSection: View {
}
}
}
.buttonStyle(.bordered)
.buttonStyle(.plain)
.foregroundColor(.secondary)
Picker("", selection: $preferredTerminal) {
ForEach(Terminal.installed, id: \.rawValue) { terminal in
HStack {
if let icon = terminal.appIcon {
Image(nsImage: icon.resized(to: NSSize(width: 16, height: 16)))
}
Text(terminal.displayName)
}
.tag(terminal.rawValue)
}
}
.pickerStyle(.menu)
.labelsHidden()
}
// Git app selector row
HStack {
Text("Preferred Git App")
Spacer()
Picker("", selection: gitAppBinding) {
ForEach(GitApp.installed, id: \.rawValue) { gitApp in
HStack {
if let icon = gitApp.appIcon {
Image(nsImage: icon.resized(to: NSSize(width: 16, height: 16)))
}
Text(gitApp.displayName)
}
.tag(gitApp.rawValue)
}
}
.pickerStyle(.menu)
.labelsHidden()
}
Text("Opens a new terminal window with a test command")
.font(.caption)
.foregroundStyle(.secondary)
}
} header: {
Text("Terminal")
Text("Apps")
.font(.headline)
} footer: {
Text(
"VibeTunnel will use this terminal when launching new terminal sessions."
"Configure which applications VibeTunnel uses for terminal sessions and Git repositories."
)
.font(.caption)
.frame(maxWidth: .infinity)
@ -329,4 +342,19 @@ private struct TerminalPreferenceSection: View {
Text(errorMessage)
}
}
private var gitAppBinding: Binding<String> {
Binding(
get: {
// If no preference or invalid preference, use first installed app
if preferredGitApp.isEmpty || GitApp(rawValue: preferredGitApp) == nil {
return GitApp.installed.first?.rawValue ?? ""
}
return preferredGitApp
},
set: { newValue in
preferredGitApp = newValue
}
)
}
}

View file

@ -11,13 +11,20 @@ struct SettingsView: View {
@AppStorage("debugMode")
private var debugMode = false
// MARK: - Constants
private enum Layout {
static let defaultTabSize = CGSize(width: 500, height: 620)
static let fallbackTabSize = CGSize(width: 500, height: 400)
}
/// Define ideal sizes for each tab
private let tabSizes: [SettingsTab: CGSize] = [
.general: CGSize(width: 500, height: 570),
.dashboard: CGSize(width: 500, height: 570),
.advanced: CGSize(width: 500, height: 570),
.debug: CGSize(width: 500, height: 570),
.about: CGSize(width: 500, height: 570)
.general: Layout.defaultTabSize,
.dashboard: Layout.defaultTabSize,
.advanced: Layout.defaultTabSize,
.debug: Layout.defaultTabSize,
.about: Layout.defaultTabSize
]
var body: some View {
@ -61,10 +68,10 @@ struct SettingsView: View {
}
}
.onChange(of: selectedTab) { _, newTab in
contentSize = tabSizes[newTab] ?? CGSize(width: 500, height: 400)
contentSize = tabSizes[newTab] ?? Layout.fallbackTabSize
}
.onAppear {
contentSize = tabSizes[selectedTab] ?? CGSize(width: 500, height: 400)
contentSize = tabSizes[selectedTab] ?? Layout.fallbackTabSize
}
.onChange(of: debugMode) { _, _ in
// If debug mode is disabled and we're on the debug tab, switch to general

View file

@ -0,0 +1,159 @@
import AppKit
import Foundation
import Observation
import os.log
import SwiftUI
/// Supported Git GUI applications.
enum GitApp: String, CaseIterable {
case cursor = "Cursor"
case fork = "Fork"
case githubDesktop = "GitHub Desktop"
case gitup = "GitUp"
case sourcetree = "SourceTree"
case sublimeMerge = "Sublime Merge"
case tower = "Tower"
case vscode = "Visual Studio Code"
case windsurf = "Windsurf"
var bundleIdentifier: String {
switch self {
case .cursor:
"com.todesktop.230313mzl4w4u92"
case .fork:
"com.DanPristupov.Fork"
case .githubDesktop:
"com.github.GitHubClient"
case .gitup:
"co.gitup.mac"
case .sourcetree:
"com.torusknot.SourceTreeNotMAS"
case .sublimeMerge:
"com.sublimemerge"
case .tower:
"com.fournova.Tower3"
case .vscode:
"com.microsoft.VSCode"
case .windsurf:
"com.codeiumapp.windsurf"
}
}
/// Priority for auto-detection (higher is better, based on popularity)
var detectionPriority: Int {
switch self {
case .cursor: 70
case .fork: 75
case .githubDesktop: 90
case .gitup: 60
case .sourcetree: 80
case .sublimeMerge: 85
case .tower: 100
case .vscode: 95
case .windsurf: 65
}
}
var displayName: String {
rawValue
}
var isInstalled: Bool {
NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleIdentifier) != nil
}
var appIcon: NSImage? {
guard let appURL = NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleIdentifier) else {
return nil
}
return NSWorkspace.shared.icon(forFile: appURL.path)
}
static var installed: [Self] {
allCases.filter(\.isInstalled)
}
/// Get the actual bundle identifier to use
var actualBundleIdentifier: String? {
isInstalled ? bundleIdentifier : nil
}
}
/// Manages launching Git applications with repository paths.
@MainActor
@Observable
final class GitAppLauncher {
static let shared = GitAppLauncher()
private let logger = Logger(subsystem: "sh.vibetunnel.VibeTunnel", category: "GitAppLauncher")
private init() {
performFirstRunAutoDetection()
}
func openRepository(at path: String) {
let gitApp = getValidGitApp()
let url = URL(fileURLWithPath: path)
if let bundleId = gitApp.actualBundleIdentifier,
let appURL = NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleId)
{
NSWorkspace.shared.open(
[url],
withApplicationAt: appURL,
configuration: NSWorkspace.OpenConfiguration()
)
} else {
// Fallback to Finder
NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: path)
}
}
func verifyPreferredGitApp() {
let currentPreference = UserDefaults.standard.string(forKey: "preferredGitApp")
if let preference = currentPreference,
let gitApp = GitApp(rawValue: preference),
!gitApp.isInstalled
{
// If the preferred app is no longer installed, clear the preference
UserDefaults.standard.removeObject(forKey: "preferredGitApp")
}
}
// MARK: - Private Methods
private func performFirstRunAutoDetection() {
// Check if git app preference has already been set
let hasSetPreference = UserDefaults.standard.object(forKey: "preferredGitApp") != nil
if !hasSetPreference {
logger.info("First run detected, auto-detecting preferred Git app")
// Check installed git apps
let installedGitApps = GitApp.installed
if let bestGitApp = installedGitApps.max(by: { $0.detectionPriority < $1.detectionPriority }) {
UserDefaults.standard.set(bestGitApp.rawValue, forKey: "preferredGitApp")
logger.info("Auto-detected and set preferred Git app to: \(bestGitApp.rawValue)")
}
}
}
private func getValidGitApp() -> GitApp {
// Read the current preference
if let currentPreference = UserDefaults.standard.string(forKey: "preferredGitApp"),
!currentPreference.isEmpty,
let gitApp = GitApp(rawValue: currentPreference),
gitApp.isInstalled
{
return gitApp
}
// No valid preference, try to find any installed Git app
let installedGitApps = GitApp.installed
if let bestGitApp = installedGitApps.max(by: { $0.detectionPriority < $1.detectionPriority }) {
return bestGitApp
}
// Default to Tower (even if not installed, we'll fall back to Finder)
return .tower
}
}

View file

@ -1,7 +1,7 @@
// VibeTunnel Version Configuration
// This file contains the version and build number for the app
MARKETING_VERSION = 1.0.0-beta.6
MARKETING_VERSION = 1.0.0-beta.7
CURRENT_PROJECT_VERSION = 160
// Domain and GitHub configuration

View file

@ -1,13 +1,12 @@
import Testing
import Foundation
import Testing
@testable import VibeTunnel
/// Tests to verify that the race condition in GitHub URL fetching is fixed
@MainActor
struct GitRepositoryMonitorRaceConditionTests {
@Test("Concurrent GitHub URL fetches don't cause duplicate Git operations")
func testConcurrentGitHubURLFetches() async throws {
func concurrentGitHubURLFetches() async throws {
let monitor = GitRepositoryMonitor()
let testRepoPath = "/test/repo/path"
@ -28,7 +27,8 @@ struct GitRepositoryMonitorRaceConditionTests {
var inProgressSet: Set<String>?
for child in mirror.children {
if child.label == "githubURLFetchesInProgress",
let set = child.value as? Set<String> {
let set = child.value as? Set<String>
{
inProgressSet = set
break
}
@ -60,7 +60,7 @@ struct GitRepositoryMonitorRaceConditionTests {
}
@Test("GitHub URL fetch completes even on failure")
func testGitHubURLFetchFailureHandling() async throws {
func gitHubURLFetchFailureHandling() async throws {
let monitor = GitRepositoryMonitor()
let invalidRepoPath = "/this/is/not/a/git/repo"
@ -75,7 +75,7 @@ struct GitRepositoryMonitorRaceConditionTests {
}
@Test("Clear cache removes in-progress fetches")
func testClearCacheRemovesInProgressFetches() async throws {
func clearCacheRemovesInProgressFetches() async throws {
let monitor = GitRepositoryMonitor()
// Start a fetch (simulated through public API)

View file

@ -361,10 +361,18 @@ main() {
trap "rm -rf $temp_dir" EXIT
# Fetch all releases from GitHub with error handling
print_info "Fetching releases from GitHub..."
print_info "Fetching releases from GitHub repository: $GITHUB_REPO_FULL"
local releases
if ! releases=$(gh api "repos/$GITHUB_REPO_FULL/releases" --paginate 2>/dev/null); then
print_error "Failed to fetch releases from GitHub. Please check your GitHub CLI authentication and network connection."
local gh_error
if ! releases=$(gh api "repos/$GITHUB_REPO_FULL/releases" --paginate 2>&1); then
gh_error=$?
print_error "Failed to fetch releases from GitHub (exit code: $gh_error)"
print_error "Repository: $GITHUB_REPO_FULL"
print_error "Error output: $releases"
print_info "Checking GitHub CLI status..."
gh auth status 2>&1 | while IFS= read -r line; do
print_info " $line"
done
exit 1
fi

193
mac/scripts/release-resume.sh Executable file
View file

@ -0,0 +1,193 @@
#!/bin/bash
# =============================================================================
# VibeTunnel Release Resume Script
# =============================================================================
#
# This script resumes a failed release process from where it left off.
# It detects the current state and continues from the appropriate step.
#
# USAGE:
# ./scripts/release-resume.sh <type> [number]
#
# ARGUMENTS:
# type Release type: stable, beta, alpha, rc
# number Pre-release number (required for beta/alpha/rc)
#
# =============================================================================
set -euo pipefail
# Color codes
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Script directory
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
# Parse arguments
RELEASE_TYPE="${1:-}"
PRERELEASE_NUMBER="${2:-}"
if [[ -z "$RELEASE_TYPE" ]]; then
echo -e "${RED}❌ Error: Release type required (stable, beta, alpha, rc)${NC}"
echo "Usage: $0 <type> [number]"
exit 1
fi
# Load version information
VERSION_CONFIG="$PROJECT_ROOT/VibeTunnel/version.xcconfig"
MARKETING_VERSION=$(grep "MARKETING_VERSION" "$VERSION_CONFIG" | cut -d' ' -f3)
BUILD_NUMBER=$(grep "CURRENT_PROJECT_VERSION" "$VERSION_CONFIG" | cut -d' ' -f3)
echo -e "${BLUE}🔄 VibeTunnel Release Resume${NC}"
echo "============================"
echo "Version: $MARKETING_VERSION"
echo "Build: $BUILD_NUMBER"
echo "Type: $RELEASE_TYPE"
echo ""
# Determine release version and tag
RELEASE_VERSION="$MARKETING_VERSION"
TAG_NAME="v$RELEASE_VERSION"
# Check what's already done
echo -e "${BLUE}🔍 Checking release state...${NC}"
# Check 1: Is the app built and notarized?
APP_PATH="$PROJECT_ROOT/build/Build/Products/Release/VibeTunnel.app"
if [[ -d "$APP_PATH" ]] && xcrun stapler validate "$APP_PATH" 2>&1 | grep -q "The validate action worked"; then
echo "✅ App is built and notarized"
APP_DONE=true
else
echo "❌ App needs to be built/notarized"
APP_DONE=false
fi
# Check 2: Does DMG exist and is it notarized?
DMG_PATH="$PROJECT_ROOT/build/VibeTunnel-$RELEASE_VERSION.dmg"
if [[ -f "$DMG_PATH" ]] && xcrun stapler validate "$DMG_PATH" 2>&1 | grep -q "The validate action worked"; then
echo "✅ DMG exists and is notarized"
DMG_DONE=true
else
echo "❌ DMG needs to be created/notarized"
DMG_DONE=false
fi
# Check 3: Does ZIP exist?
ZIP_PATH="$PROJECT_ROOT/build/VibeTunnel-$RELEASE_VERSION.zip"
if [[ -f "$ZIP_PATH" ]]; then
echo "✅ ZIP exists"
ZIP_DONE=true
else
echo "❌ ZIP needs to be created"
ZIP_DONE=false
fi
# Check 4: Is GitHub release created?
if gh release view "$TAG_NAME" >/dev/null 2>&1; then
echo "✅ GitHub release exists"
GITHUB_DONE=true
else
echo "❌ GitHub release needs to be created"
GITHUB_DONE=false
fi
# Check 5: Is appcast updated?
APPCAST_FILE="$PROJECT_ROOT/../appcast-prerelease.xml"
if [[ "$RELEASE_TYPE" == "stable" ]]; then
APPCAST_FILE="$PROJECT_ROOT/../appcast.xml"
fi
if grep -q "<sparkle:version>$BUILD_NUMBER</sparkle:version>" "$APPCAST_FILE" 2>/dev/null; then
echo "✅ Appcast is updated"
APPCAST_DONE=true
else
echo "❌ Appcast needs to be updated"
APPCAST_DONE=false
fi
echo ""
# Resume from appropriate step
if [[ "$APP_DONE" == "false" ]]; then
echo -e "${RED}❌ App not built/notarized. Please run the full release script.${NC}"
exit 1
fi
if [[ "$DMG_DONE" == "false" ]]; then
echo -e "${BLUE}📋 Creating and notarizing DMG...${NC}"
"$SCRIPT_DIR/create-dmg.sh" "$APP_PATH" "$DMG_PATH"
"$SCRIPT_DIR/notarize-dmg.sh" "$DMG_PATH"
echo -e "${GREEN}✅ DMG created and notarized${NC}"
fi
if [[ "$ZIP_DONE" == "false" ]]; then
echo -e "${BLUE}📋 Creating ZIP...${NC}"
"$SCRIPT_DIR/create-zip.sh" "$APP_PATH" "$ZIP_PATH"
echo -e "${GREEN}✅ ZIP created${NC}"
fi
if [[ "$GITHUB_DONE" == "false" ]]; then
echo -e "${BLUE}📋 Creating GitHub release...${NC}"
# Create tag if it doesn't exist
if ! git rev-parse "$TAG_NAME" >/dev/null 2>&1; then
git tag -a "$TAG_NAME" -m "Release $RELEASE_VERSION (build $BUILD_NUMBER)"
git push origin "$TAG_NAME"
fi
# Sign DMG for Sparkle
echo "🔐 Signing DMG for Sparkle..."
DMG_SIGNATURE=$(sign_update "$DMG_PATH" --account VibeTunnel | grep "sparkle:edSignature" | cut -d'"' -f2)
echo " Signature: $DMG_SIGNATURE"
# Create release
if [[ "$RELEASE_TYPE" == "stable" ]]; then
gh release create "$TAG_NAME" \
--title "VibeTunnel $RELEASE_VERSION" \
--notes-file "$PROJECT_ROOT/CHANGELOG.md" \
"$DMG_PATH" \
"$ZIP_PATH"
else
gh release create "$TAG_NAME" \
--title "VibeTunnel $RELEASE_VERSION" \
--notes-file "$PROJECT_ROOT/CHANGELOG.md" \
--prerelease \
"$DMG_PATH" \
"$ZIP_PATH"
fi
echo -e "${GREEN}✅ GitHub release created${NC}"
fi
if [[ "$APPCAST_DONE" == "false" ]]; then
echo -e "${BLUE}📋 Updating appcast...${NC}"
export SPARKLE_ACCOUNT="VibeTunnel"
"$SCRIPT_DIR/generate-appcast.sh"
# Commit and push appcast
git add "$PROJECT_ROOT/../appcast*.xml" 2>/dev/null || true
if ! git diff --cached --quiet; then
git commit -m "Update appcast for $RELEASE_VERSION"
git push origin main
fi
echo -e "${GREEN}✅ Appcast updated${NC}"
fi
echo ""
echo -e "${GREEN}🎉 Release Resume Complete!${NC}"
echo "========================="
echo ""
echo -e "${GREEN}✅ Successfully completed release of VibeTunnel $RELEASE_VERSION${NC}"
echo ""
echo "Release details:"
echo " - Version: $RELEASE_VERSION"
echo " - Build: $BUILD_NUMBER"
echo " - Tag: $TAG_NAME"
echo " - GitHub: https://github.com/amantus-ai/vibetunnel/releases/tag/$TAG_NAME"

View file

@ -15,6 +15,11 @@
# type Release type: stable, beta, alpha, rc
# number Pre-release number (required for beta/alpha/rc)
#
# IMPORTANT NOTES:
# - This script can take 10-15 minutes due to notarization
# - If running from Claude or other tools with timeouts, use a longer timeout
# - If the script fails partway, use release-resume.sh to continue
#
# FEATURES:
# - Complete build and release automation
# - Automatic IS_PRERELEASE_BUILD flag handling

113
mac/scripts/update-appcast.sh Executable file
View file

@ -0,0 +1,113 @@
#!/bin/bash
# =============================================================================
# VibeTunnel Appcast Update Script
# =============================================================================
#
# This script updates the appcast files after a release has been created.
# It fetches release information from GitHub and regenerates the appcast XML.
#
# USAGE:
# ./scripts/update-appcast.sh
#
# DEPENDENCIES:
# - GitHub CLI (gh) authenticated
# - Sparkle tools (sign_update) in ~/.local/bin
# - generate-appcast.sh script
#
# =============================================================================
set -euo pipefail
# Color codes
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Script directory
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
echo -e "${BLUE}🔄 VibeTunnel Appcast Update${NC}"
echo "============================"
# Check GitHub CLI authentication
if ! gh auth status &>/dev/null; then
echo -e "${RED}❌ Error: GitHub CLI not authenticated${NC}"
echo "Run: gh auth login"
exit 1
fi
# Check for Sparkle tools
if ! command -v sign_update &>/dev/null; then
export PATH="$HOME/.local/bin:$PATH"
if ! command -v sign_update &>/dev/null; then
echo -e "${RED}❌ Error: sign_update not found in PATH${NC}"
echo "Please install Sparkle tools to ~/.local/bin/"
exit 1
fi
fi
# Set Sparkle account
export SPARKLE_ACCOUNT="VibeTunnel"
echo "Using Sparkle account: $SPARKLE_ACCOUNT"
# Run generate-appcast.sh
echo ""
echo -e "${BLUE}📋 Generating appcast files...${NC}"
if "$SCRIPT_DIR/generate-appcast.sh"; then
echo -e "${GREEN}✅ Appcast generation completed${NC}"
else
echo -e "${RED}❌ Appcast generation failed${NC}"
exit 1
fi
# Verify appcast files exist
APPCAST_STABLE="$PROJECT_ROOT/../appcast.xml"
APPCAST_PRERELEASE="$PROJECT_ROOT/../appcast-prerelease.xml"
if [[ ! -f "$APPCAST_STABLE" ]]; then
echo -e "${YELLOW}⚠️ Warning: appcast.xml not found${NC}"
fi
if [[ ! -f "$APPCAST_PRERELEASE" ]]; then
echo -e "${YELLOW}⚠️ Warning: appcast-prerelease.xml not found${NC}"
fi
# Check if there are changes to commit
cd "$PROJECT_ROOT/.."
if git diff --quiet appcast*.xml 2>/dev/null; then
echo ""
echo " No changes to appcast files"
else
echo ""
echo -e "${BLUE}📤 Committing appcast changes...${NC}"
# Show what changed
echo "Changes detected:"
git diff --stat appcast*.xml
# Add and commit
git add appcast*.xml
git commit -m "Update appcast files"
# Push changes
echo "Pushing changes..."
git push origin main
echo -e "${GREEN}✅ Appcast changes committed and pushed${NC}"
fi
# Run verification
echo ""
echo -e "${BLUE}🔍 Verifying appcast files...${NC}"
if "$SCRIPT_DIR/verify-appcast.sh"; then
echo -e "${GREEN}✅ Appcast verification passed${NC}"
else
echo -e "${YELLOW}⚠️ Some appcast issues detected${NC}"
fi
echo ""
echo -e "${GREEN}✅ Appcast update complete!${NC}"

View file

@ -1,6 +1,6 @@
{
"name": "@vibetunnel/vibetunnel-cli",
"version": "1.0.0-beta.6",
"version": "1.0.0-beta.7",
"description": "Web frontend for terminal multiplexer",
"main": "dist/server.js",
"bin": {

View file

@ -824,19 +824,23 @@ export class SessionView extends LitElement {
throw new Error(`Rename failed: ${response.status}`);
}
// Update the local session object
this.session = { ...this.session, name: newName };
// Get the actual name from the server response
const result = await response.json();
const actualName = result.name || newName;
// Dispatch event to notify parent components
// Update the local session object with the server-assigned name
this.session = { ...this.session, name: actualName };
// Dispatch event to notify parent components with the actual name
this.dispatchEvent(
new CustomEvent('session-renamed', {
detail: { sessionId, newName },
detail: { sessionId, newName: actualName },
bubbles: true,
composed: true,
})
);
logger.log(`Session ${sessionId} renamed to: ${newName}`);
logger.log(`Session ${sessionId} renamed to: ${actualName}`);
} catch (error) {
logger.error('Error renaming session', { error, sessionId });

View file

@ -1019,21 +1019,21 @@ export class PtyManager extends EventEmitter {
/**
* Update session name
*/
updateSessionName(sessionId: string, name: string): void {
updateSessionName(sessionId: string, name: string): string {
logger.debug(
`[PtyManager] updateSessionName called for session ${sessionId} with name: ${name}`
);
// Update in session manager (persisted storage)
// Update in session manager (persisted storage) - get the unique name back
logger.debug(`[PtyManager] Calling sessionManager.updateSessionName`);
this.sessionManager.updateSessionName(sessionId, name);
const uniqueName = this.sessionManager.updateSessionName(sessionId, name);
// Update in-memory session if it exists
const memorySession = this.sessions.get(sessionId);
if (memorySession?.sessionInfo) {
logger.debug(`[PtyManager] Found in-memory session, updating...`);
const oldName = memorySession.sessionInfo.name;
memorySession.sessionInfo.name = name;
memorySession.sessionInfo.name = uniqueName;
logger.debug(`[PtyManager] Session info after update:`, {
sessionId: memorySession.id,
@ -1054,7 +1054,9 @@ export class PtyManager extends EventEmitter {
this.updateTerminalTitleForSessionName(memorySession);
}
logger.log(`[PtyManager] Updated session ${sessionId} name from "${oldName}" to "${name}"`);
logger.log(
`[PtyManager] Updated session ${sessionId} name from "${oldName}" to "${uniqueName}"`
);
} else {
logger.debug(`[PtyManager] No in-memory session found for ${sessionId}`, {
sessionsMapSize: this.sessions.size,
@ -1063,9 +1065,11 @@ export class PtyManager extends EventEmitter {
}
// Emit event for clients to refresh their session data
this.trackAndEmit('sessionNameChanged', sessionId, name);
this.trackAndEmit('sessionNameChanged', sessionId, uniqueName);
logger.log(`[PtyManager] Updated session ${sessionId} name to: ${name}`);
logger.log(`[PtyManager] Updated session ${sessionId} name to: ${uniqueName}`);
return uniqueName;
}
/**

View file

@ -194,10 +194,36 @@ export class SessionManager {
);
}
/**
* Ensure a session name is unique by adding a suffix if necessary
*/
private ensureUniqueName(desiredName: string, excludeSessionId?: string): string {
const sessions = this.listSessions();
let finalName = desiredName;
let suffix = 2;
// Keep checking until we find a unique name
while (true) {
const nameExists = sessions.some(
(session) => session.name === finalName && session.id !== excludeSessionId
);
if (!nameExists) {
break;
}
// Add or increment suffix
finalName = `${desiredName} (${suffix})`;
suffix++;
}
return finalName;
}
/**
* Update session name
*/
updateSessionName(sessionId: string, name: string): void {
updateSessionName(sessionId: string, name: string): string {
logger.debug(
`[SessionManager] updateSessionName called for session ${sessionId} with name: ${name}`
);
@ -210,13 +236,22 @@ export class SessionManager {
logger.debug(`[SessionManager] Current session info: ${JSON.stringify(sessionInfo)}`);
sessionInfo.name = name;
// Ensure the name is unique
const uniqueName = this.ensureUniqueName(name, sessionId);
if (uniqueName !== name) {
logger.log(`[SessionManager] Name "${name}" already exists, using "${uniqueName}" instead`);
}
sessionInfo.name = uniqueName;
logger.debug(`[SessionManager] Updated session info: ${JSON.stringify(sessionInfo)}`);
logger.debug(`[SessionManager] Calling saveSessionInfo`);
this.saveSessionInfo(sessionId, sessionInfo);
logger.log(`[SessionManager] session ${sessionId} name updated to: ${name}`);
logger.log(`[SessionManager] session ${sessionId} name updated to: ${uniqueName}`);
return uniqueName;
}
/**

View file

@ -1091,10 +1091,10 @@ export function createSessionRoutes(config: SessionRoutesConfig): Router {
// Update the session name
logger.debug(`[PATCH] Calling ptyManager.updateSessionName(${sessionId}, ${name})`);
ptyManager.updateSessionName(sessionId, name);
logger.log(chalk.green(`[PATCH] Session ${sessionId} name updated to: ${name}`));
const uniqueName = ptyManager.updateSessionName(sessionId, name);
logger.log(chalk.green(`[PATCH] Session ${sessionId} name updated to: ${uniqueName}`));
res.json({ success: true, name });
res.json({ success: true, name: uniqueName });
} catch (error) {
logger.error('error updating session name:', error);
if (error instanceof PtyError) {