Add macOS app foundation with release infrastructure (#1)

This commit is contained in:
Peter Steinberger 2025-06-15 23:14:29 +02:00 committed by GitHub
parent a47c522967
commit b5644d2b17
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
70 changed files with 128374 additions and 1 deletions

BIN
.DS_Store vendored Normal file

Binary file not shown.

8
.github-config Normal file
View file

@ -0,0 +1,8 @@
# GitHub Configuration for VibeTunnel
# This file contains configuration values for the release scripts
# GitHub username/organization for the repository
GITHUB_USERNAME="amantus-ai"
# Repository name
GITHUB_REPO="vibetunnel"

131
README.md
View file

@ -1 +1,130 @@
# vibetunnel
# VibeTunnel
VibeTunnel is a Mac app that proxies terminal apps to the web. Now you can use Claude Code anywhere, anytime. Control open instances, read the output, type new commands or even open new instances. Supports macOS 14+.
## Overview
VibeTunnel is a native macOS app built with SwiftUI that enables remote control of terminal applications like Claude Code. It provides a seamless tunneling solution with automatic updates, configurable settings, and the ability to run as either a dock application or menu bar utility.
## Features
- **Remote Terminal Control**: Tunnel connections to control Claude Code and other terminal apps remotely
- **Flexible UI Modes**: Run as a standard dock application or minimal menu bar utility
- **Auto Updates**: Built-in Sparkle integration for seamless updates with stable and pre-release channels
- **Launch at Login**: Automatic startup configuration
- **Native macOS Experience**: Built with SwiftUI for macOS 14.0+
## Requirements
- macOS 14.0 or later
- Xcode 15.0 or later (for development)
## Installation
### From Release
1. Download the latest DMG from the [Releases](https://github.com/yourusername/vibetunnel/releases) page
2. Open the DMG and drag VibeTunnel to your Applications folder
3. Launch VibeTunnel from Applications or Spotlight
### From Source
```bash
# Clone the repository
git clone https://github.com/yourusername/vibetunnel.git
cd vibetunnel
# Build using Xcode
open VibeTunnel.xcodeproj
# Or build from command line
xcodebuild -scheme VibeTunnel -configuration Release
```
## Configuration
VibeTunnel can be configured through its Settings window (⌘,):
### General Settings
- **Launch at Login**: Start VibeTunnel automatically when you log in
- **Show Notifications**: Enable/disable system notifications
- **Show in Dock**: Toggle between dock app and menu bar only mode
### Advanced Settings
- **Update Channel**: Choose between stable releases or pre-release builds
- **Server Port**: Configure the tunnel server port (default: 8080)
- **Debug Mode**: Enable additional logging for troubleshooting
## Development
### Project Structure
```
VibeTunnel/
├── VibeTunnel/ # Main app source
│ ├── Core/ # Core functionality
│ │ ├── Models/ # Data models
│ │ └── Services/ # Business logic
│ ├── Views/ # SwiftUI views
│ └── Resources/ # Assets and resources
├── VibeTunnelTests/ # Unit tests
├── VibeTunnelUITests/ # UI tests
├── scripts/ # Build and release automation
└── docs/ # Documentation
```
### Building
The project uses standard Xcode build system:
```bash
# Debug build
xcodebuild -scheme VibeTunnel -configuration Debug
# Release build
xcodebuild -scheme VibeTunnel -configuration Release
# Run tests
xcodebuild test -scheme VibeTunnel
```
### Release Process
The project includes comprehensive release automation scripts in the `scripts/` directory:
```bash
# Create a new release
./scripts/release.sh --version 1.2.3
# Build and notarize
./scripts/build.sh
./scripts/notarize.sh
# Generate appcast for Sparkle updates
./scripts/generate-appcast.sh
```
## Architecture
VibeTunnel is built with a modular architecture:
- **SparkleUpdaterManager**: Handles automatic updates with support for multiple update channels
- **StartupManager**: Manages launch at login functionality using macOS ServiceManagement
- **UpdateChannel**: Defines update channels and appcast URLs
- **AppDelegate**: Coordinates app lifecycle and system integration
## Contributing
1. Fork the repository
2. Create your feature branch (`git checkout -b feature/amazing-feature`)
3. Commit your changes (`git commit -m 'Add amazing feature'`)
4. Push to the branch (`git push origin feature/amazing-feature`)
5. Open a Pull Request
## License
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
## Author
Created by Peter Steinberger / Amantus Machina
---
**Note**: VibeTunnel is currently in active development. Core tunneling functionality is being implemented.

View file

@ -0,0 +1,619 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 77;
objects = {
/* Begin PBXBuildFile section */
788688322DFF700200B22C15 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = 788688312DFF700100B22C15 /* Sparkle */; };
788688552DFF5EAB00B22C15 /* Hummingbird in Frameworks */ = {isa = PBXBuildFile; productRef = 788688212DFF600100B22C15 /* Hummingbird */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
788687FF2DFF4FCB00B22C15 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 788687E92DFF4FCB00B22C15 /* Project object */;
proxyType = 1;
remoteGlobalIDString = 788687F02DFF4FCB00B22C15;
remoteInfo = VibeTunnel;
};
788688092DFF4FCC00B22C15 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 788687E92DFF4FCB00B22C15 /* Project object */;
proxyType = 1;
remoteGlobalIDString = 788687F02DFF4FCB00B22C15;
remoteInfo = VibeTunnel;
};
/* End PBXContainerItemProxy section */
/* Begin PBXFileReference section */
788687F12DFF4FCB00B22C15 /* VibeTunnel.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = VibeTunnel.app; sourceTree = BUILT_PRODUCTS_DIR; };
788687FE2DFF4FCB00B22C15 /* VibeTunnelTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = VibeTunnelTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
788688082DFF4FCC00B22C15 /* VibeTunnelUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = VibeTunnelUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
788687F32DFF4FCB00B22C15 /* VibeTunnel */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = VibeTunnel;
sourceTree = "<group>";
};
788688012DFF4FCB00B22C15 /* VibeTunnelTests */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = VibeTunnelTests;
sourceTree = "<group>";
};
7886880B2DFF4FCC00B22C15 /* VibeTunnelUITests */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = VibeTunnelUITests;
sourceTree = "<group>";
};
/* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */
788687EE2DFF4FCB00B22C15 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
788688552DFF5EAB00B22C15 /* Hummingbird in Frameworks */,
788688322DFF700200B22C15 /* Sparkle in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
788687FB2DFF4FCB00B22C15 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
788688052DFF4FCC00B22C15 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
788687E82DFF4FCB00B22C15 = {
isa = PBXGroup;
children = (
788687F32DFF4FCB00B22C15 /* VibeTunnel */,
788688012DFF4FCB00B22C15 /* VibeTunnelTests */,
7886880B2DFF4FCC00B22C15 /* VibeTunnelUITests */,
788687F22DFF4FCB00B22C15 /* Products */,
);
sourceTree = "<group>";
};
788687F22DFF4FCB00B22C15 /* Products */ = {
isa = PBXGroup;
children = (
788687F12DFF4FCB00B22C15 /* VibeTunnel.app */,
788687FE2DFF4FCB00B22C15 /* VibeTunnelTests.xctest */,
788688082DFF4FCC00B22C15 /* VibeTunnelUITests.xctest */,
);
name = Products;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
788687F02DFF4FCB00B22C15 /* VibeTunnel */ = {
isa = PBXNativeTarget;
buildConfigurationList = 788688122DFF4FCC00B22C15 /* Build configuration list for PBXNativeTarget "VibeTunnel" */;
buildPhases = (
788687ED2DFF4FCB00B22C15 /* Sources */,
788687EE2DFF4FCB00B22C15 /* Frameworks */,
788687EF2DFF4FCB00B22C15 /* Resources */,
);
buildRules = (
);
dependencies = (
);
fileSystemSynchronizedGroups = (
788687F32DFF4FCB00B22C15 /* VibeTunnel */,
);
name = VibeTunnel;
packageProductDependencies = (
788688212DFF600100B22C15 /* Hummingbird */,
788688312DFF700100B22C15 /* Sparkle */,
);
productName = VibeTunnel;
productReference = 788687F12DFF4FCB00B22C15 /* VibeTunnel.app */;
productType = "com.apple.product-type.application";
};
788687FD2DFF4FCB00B22C15 /* VibeTunnelTests */ = {
isa = PBXNativeTarget;
buildConfigurationList = 788688152DFF4FCC00B22C15 /* Build configuration list for PBXNativeTarget "VibeTunnelTests" */;
buildPhases = (
788687FA2DFF4FCB00B22C15 /* Sources */,
788687FB2DFF4FCB00B22C15 /* Frameworks */,
788687FC2DFF4FCB00B22C15 /* Resources */,
);
buildRules = (
);
dependencies = (
788688002DFF4FCB00B22C15 /* PBXTargetDependency */,
);
fileSystemSynchronizedGroups = (
788688012DFF4FCB00B22C15 /* VibeTunnelTests */,
);
name = VibeTunnelTests;
packageProductDependencies = (
);
productName = VibeTunnelTests;
productReference = 788687FE2DFF4FCB00B22C15 /* VibeTunnelTests.xctest */;
productType = "com.apple.product-type.bundle.unit-test";
};
788688072DFF4FCC00B22C15 /* VibeTunnelUITests */ = {
isa = PBXNativeTarget;
buildConfigurationList = 788688182DFF4FCC00B22C15 /* Build configuration list for PBXNativeTarget "VibeTunnelUITests" */;
buildPhases = (
788688042DFF4FCC00B22C15 /* Sources */,
788688052DFF4FCC00B22C15 /* Frameworks */,
788688062DFF4FCC00B22C15 /* Resources */,
);
buildRules = (
);
dependencies = (
7886880A2DFF4FCC00B22C15 /* PBXTargetDependency */,
);
fileSystemSynchronizedGroups = (
7886880B2DFF4FCC00B22C15 /* VibeTunnelUITests */,
);
name = VibeTunnelUITests;
packageProductDependencies = (
);
productName = VibeTunnelUITests;
productReference = 788688082DFF4FCC00B22C15 /* VibeTunnelUITests.xctest */;
productType = "com.apple.product-type.bundle.ui-testing";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
788687E92DFF4FCB00B22C15 /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 2600;
LastUpgradeCheck = 2600;
TargetAttributes = {
788687F02DFF4FCB00B22C15 = {
CreatedOnToolsVersion = 26.0;
};
788687FD2DFF4FCB00B22C15 = {
CreatedOnToolsVersion = 26.0;
TestTargetID = 788687F02DFF4FCB00B22C15;
};
788688072DFF4FCC00B22C15 = {
CreatedOnToolsVersion = 26.0;
TestTargetID = 788687F02DFF4FCB00B22C15;
};
};
};
buildConfigurationList = 788687EC2DFF4FCB00B22C15 /* Build configuration list for PBXProject "VibeTunnel" */;
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = 788687E82DFF4FCB00B22C15;
minimizedProjectReferenceProxies = 1;
packageReferences = (
788688202DFF600000B22C15 /* XCRemoteSwiftPackageReference "hummingbird" */,
788688302DFF700000B22C15 /* XCRemoteSwiftPackageReference "Sparkle" */,
);
preferredProjectObjectVersion = 77;
productRefGroup = 788687F22DFF4FCB00B22C15 /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
788687F02DFF4FCB00B22C15 /* VibeTunnel */,
788687FD2DFF4FCB00B22C15 /* VibeTunnelTests */,
788688072DFF4FCC00B22C15 /* VibeTunnelUITests */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
788687EF2DFF4FCB00B22C15 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
788687FC2DFF4FCB00B22C15 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
788688062DFF4FCC00B22C15 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
788687ED2DFF4FCB00B22C15 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
788687FA2DFF4FCB00B22C15 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
788688042DFF4FCC00B22C15 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
788688002DFF4FCB00B22C15 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 788687F02DFF4FCB00B22C15 /* VibeTunnel */;
targetProxy = 788687FF2DFF4FCB00B22C15 /* PBXContainerItemProxy */;
};
7886880A2DFF4FCC00B22C15 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 788687F02DFF4FCB00B22C15 /* VibeTunnel */;
targetProxy = 788688092DFF4FCC00B22C15 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin XCBuildConfiguration section */
788688102DFF4FCC00B22C15 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = Y5PE65HELJ;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MACOSX_DEPLOYMENT_TARGET = 14.0;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = macosx;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
};
name = Debug;
};
788688112DFF4FCC00B22C15 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = Y5PE65HELJ;
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MACOSX_DEPLOYMENT_TARGET = 14.0;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
SDKROOT = macosx;
SWIFT_COMPILATION_MODE = wholemodule;
};
name = Release;
};
788688132DFF4FCC00B22C15 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 100;
DEVELOPMENT_TEAM = Y5PE65HELJ;
ENABLE_APP_SANDBOX = YES;
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES;
ENABLE_USER_SELECTED_FILES = readonly;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = VibeTunnel/Info.plist;
INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2024 Amantus AI. All rights reserved.";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
);
MARKETING_VERSION = 0.1;
PRODUCT_BUNDLE_IDENTIFIER = com.amantus.vibetunnel;
PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
};
name = Debug;
};
788688142DFF4FCC00B22C15 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 100;
DEVELOPMENT_TEAM = Y5PE65HELJ;
ENABLE_APP_SANDBOX = YES;
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES;
ENABLE_USER_SELECTED_FILES = readonly;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = VibeTunnel/Info.plist;
INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2024 Amantus AI. All rights reserved.";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
);
MARKETING_VERSION = 0.1;
PRODUCT_BUNDLE_IDENTIFIER = com.amantus.vibetunnel;
PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
};
name = Release;
};
788688162DFF4FCC00B22C15 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 100;
DEVELOPMENT_TEAM = Y5PE65HELJ;
GENERATE_INFOPLIST_FILE = YES;
MACOSX_DEPLOYMENT_TARGET = 14.0;
MARKETING_VERSION = 0.1;
PRODUCT_BUNDLE_IDENTIFIER = com.amantus.vibetunnel.Tests;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = NO;
SWIFT_EMIT_LOC_STRINGS = NO;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/VibeTunnel.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/VibeTunnel";
};
name = Debug;
};
788688172DFF4FCC00B22C15 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 100;
DEVELOPMENT_TEAM = Y5PE65HELJ;
GENERATE_INFOPLIST_FILE = YES;
MACOSX_DEPLOYMENT_TARGET = 14.0;
MARKETING_VERSION = 0.1;
PRODUCT_BUNDLE_IDENTIFIER = com.amantus.vibetunnel.Tests;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = NO;
SWIFT_EMIT_LOC_STRINGS = NO;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/VibeTunnel.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/VibeTunnel";
};
name = Release;
};
788688192DFF4FCC00B22C15 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 100;
DEVELOPMENT_TEAM = Y5PE65HELJ;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 0.1;
PRODUCT_BUNDLE_IDENTIFIER = com.amantus.vibetunnel.UITests;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = NO;
SWIFT_EMIT_LOC_STRINGS = NO;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TEST_TARGET_NAME = VibeTunnel;
};
name = Debug;
};
7886881A2DFF4FCC00B22C15 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 100;
DEVELOPMENT_TEAM = Y5PE65HELJ;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 0.1;
PRODUCT_BUNDLE_IDENTIFIER = com.amantus.vibetunnel.UITests;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = NO;
SWIFT_EMIT_LOC_STRINGS = NO;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TEST_TARGET_NAME = VibeTunnel;
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
788687EC2DFF4FCB00B22C15 /* Build configuration list for PBXProject "VibeTunnel" */ = {
isa = XCConfigurationList;
buildConfigurations = (
788688102DFF4FCC00B22C15 /* Debug */,
788688112DFF4FCC00B22C15 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
788688122DFF4FCC00B22C15 /* Build configuration list for PBXNativeTarget "VibeTunnel" */ = {
isa = XCConfigurationList;
buildConfigurations = (
788688132DFF4FCC00B22C15 /* Debug */,
788688142DFF4FCC00B22C15 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
788688152DFF4FCC00B22C15 /* Build configuration list for PBXNativeTarget "VibeTunnelTests" */ = {
isa = XCConfigurationList;
buildConfigurations = (
788688162DFF4FCC00B22C15 /* Debug */,
788688172DFF4FCC00B22C15 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
788688182DFF4FCC00B22C15 /* Build configuration list for PBXNativeTarget "VibeTunnelUITests" */ = {
isa = XCConfigurationList;
buildConfigurations = (
788688192DFF4FCC00B22C15 /* Debug */,
7886881A2DFF4FCC00B22C15 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
/* Begin XCRemoteSwiftPackageReference section */
788688202DFF600000B22C15 /* XCRemoteSwiftPackageReference "hummingbird" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/hummingbird-project/hummingbird.git";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 2.6.1;
};
};
788688302DFF700000B22C15 /* XCRemoteSwiftPackageReference "Sparkle" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/sparkle-project/Sparkle.git";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 2.6.4;
};
};
/* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
788688212DFF600100B22C15 /* Hummingbird */ = {
isa = XCSwiftPackageProductDependency;
package = 788688202DFF600000B22C15 /* XCRemoteSwiftPackageReference "hummingbird" */;
productName = Hummingbird;
};
788688312DFF700100B22C15 /* Sparkle */ = {
isa = XCSwiftPackageProductDependency;
package = 788688302DFF700000B22C15 /* XCRemoteSwiftPackageReference "Sparkle" */;
productName = Sparkle;
};
/* End XCSwiftPackageProductDependency section */
};
rootObject = 788687E92DFF4FCB00B22C15 /* Project object */;
}

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>

View file

@ -0,0 +1,186 @@
{
"originHash" : "379fc445189c9b1b1650a73f06eaf74b6b2e04a5c65e401ab81c9bb88fa78a17",
"pins" : [
{
"identity" : "async-http-client",
"kind" : "remoteSourceControl",
"location" : "https://github.com/swift-server/async-http-client.git",
"state" : {
"revision" : "0ae99db85b2b9d1e79b362bd31fd1ffe492f7c47",
"version" : "1.21.2"
}
},
{
"identity" : "hummingbird",
"kind" : "remoteSourceControl",
"location" : "https://github.com/hummingbird-project/hummingbird.git",
"state" : {
"revision" : "65ace7855fa8413d6218adeecaf706f2b99c23c1",
"version" : "2.14.1"
}
},
{
"identity" : "sparkle",
"kind" : "remoteSourceControl",
"location" : "https://github.com/sparkle-project/Sparkle.git",
"state" : {
"revision" : "0ca3004e98712ea2b39dd881d28448630cce1c99",
"version" : "2.7.0"
}
},
{
"identity" : "swift-algorithms",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-algorithms",
"state" : {
"revision" : "87e50f483c54e6efd60e885f7f5aa946cee68023",
"version" : "1.2.1"
}
},
{
"identity" : "swift-async-algorithms",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-async-algorithms.git",
"state" : {
"revision" : "042e1c4d9d19748c9c228f8d4ebc97bb1e339b0b",
"version" : "1.0.4"
}
},
{
"identity" : "swift-atomics",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-atomics.git",
"state" : {
"revision" : "cd142fd2f64be2100422d658e7411e39489da985",
"version" : "1.2.0"
}
},
{
"identity" : "swift-collections",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-collections.git",
"state" : {
"revision" : "94cf62b3ba8d4bed62680a282d4c25f9c63c2efb",
"version" : "1.1.0"
}
},
{
"identity" : "swift-distributed-tracing",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-distributed-tracing.git",
"state" : {
"revision" : "a64a0abc2530f767af15dd88dda7f64d5f1ff9de",
"version" : "1.2.0"
}
},
{
"identity" : "swift-http-types",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-http-types.git",
"state" : {
"revision" : "12358d55a3824bd5fed310b999ea8cf83a9a1a65",
"version" : "1.0.3"
}
},
{
"identity" : "swift-log",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-log.git",
"state" : {
"revision" : "e97a6fcb1ab07462881ac165fdbb37f067e205d5",
"version" : "1.5.4"
}
},
{
"identity" : "swift-metrics",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-metrics.git",
"state" : {
"revision" : "4c83e1cdf4ba538ef6e43a9bbd0bcc33a0ca46e3",
"version" : "2.7.0"
}
},
{
"identity" : "swift-nio",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-nio.git",
"state" : {
"revision" : "34d486b01cd891297ac615e40d5999536a1e138d",
"version" : "2.83.0"
}
},
{
"identity" : "swift-nio-extras",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-nio-extras.git",
"state" : {
"revision" : "a3b640d7dc567225db7c94386a6e71aded1bfa63",
"version" : "1.22.0"
}
},
{
"identity" : "swift-nio-http2",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-nio-http2.git",
"state" : {
"revision" : "4281466512f63d1bd530e33f4aa6993ee7864be0",
"version" : "1.36.0"
}
},
{
"identity" : "swift-nio-ssl",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-nio-ssl.git",
"state" : {
"revision" : "7c381eb6083542b124a6c18fae742f55001dc2b5",
"version" : "2.26.0"
}
},
{
"identity" : "swift-nio-transport-services",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-nio-transport-services.git",
"state" : {
"revision" : "6cbe0ed2b394f21ab0d46b9f0c50c6be964968ce",
"version" : "1.20.1"
}
},
{
"identity" : "swift-numerics",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-numerics.git",
"state" : {
"revision" : "e0ec0f5f3af6f3e4d5e7a19d2af26b481acb6ba8",
"version" : "1.0.3"
}
},
{
"identity" : "swift-service-context",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-service-context.git",
"state" : {
"revision" : "1983448fefc717a2bc2ebde5490fe99873c5b8a6",
"version" : "1.2.1"
}
},
{
"identity" : "swift-service-lifecycle",
"kind" : "remoteSourceControl",
"location" : "https://github.com/swift-server/swift-service-lifecycle.git",
"state" : {
"revision" : "24c800fb494fbee6e42bc156dc94232dc08971af",
"version" : "2.6.1"
}
},
{
"identity" : "swift-system",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-system.git",
"state" : {
"revision" : "61e4ca4b81b9e09e2ec863b00c340eb13497dac6",
"version" : "1.5.0"
}
}
],
"version" : 3
}

View file

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>SchemeUserState</key>
<dict>
<key>VibeTunnel.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>0</integer>
</dict>
</dict>
</dict>
</plist>

BIN
VibeTunnel/.DS_Store vendored Normal file

Binary file not shown.

BIN
VibeTunnel/Assets.xcassets/.DS_Store vendored Normal file

Binary file not shown.

View file

@ -0,0 +1,20 @@
{
"colors" : [
{
"color" : {
"color-space" : "display-p3",
"components" : {
"alpha" : "1.000",
"blue" : "0.560",
"green" : "1.000",
"red" : "0.153"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View file

@ -0,0 +1,68 @@
{
"images" : [
{
"filename" : "icon_16x16.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "16x16"
},
{
"filename" : "icon_16x16@2x.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "16x16"
},
{
"filename" : "icon_32x32.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "32x32"
},
{
"filename" : "icon_32x32@2x.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "32x32"
},
{
"filename" : "icon_128x128.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "128x128"
},
{
"filename" : "icon_128x128@2x.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "128x128"
},
{
"filename" : "icon_256x256.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "256x256"
},
{
"filename" : "icon_256x256@2x.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "256x256"
},
{
"filename" : "icon_512x512.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "512x512"
},
{
"filename" : "icon_512x512@2x.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "512x512"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 719 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 343 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 343 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

View file

@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View file

@ -0,0 +1,26 @@
{
"images" : [
{
"filename" : "menubar.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "menubar@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "menubar@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"template-rendering-intent" : "template"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 897 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

BIN
VibeTunnel/Core/.DS_Store vendored Normal file

Binary file not shown.

View file

@ -0,0 +1,72 @@
//
// TunnelSession.swift
// VibeTunnel
//
// Created by VibeTunnel on 15.06.25.
//
import Foundation
/// Represents a terminal session that can be controlled remotely
struct TunnelSession: Identifiable, Codable {
let id: UUID
let createdAt: Date
var lastActivity: Date
let processID: Int32?
var isActive: Bool
init(id: UUID = UUID(), processID: Int32? = nil) {
self.id = id
self.createdAt = Date()
self.lastActivity = Date()
self.processID = processID
self.isActive = true
}
mutating func updateActivity() {
self.lastActivity = Date()
}
}
/// Request to create a new terminal session
struct CreateSessionRequest: Codable {
let workingDirectory: String?
let environment: [String: String]?
let shell: String?
}
/// Response after creating a session
struct CreateSessionResponse: Codable {
let sessionId: String
let createdAt: Date
}
/// Command execution request
struct CommandRequest: Codable {
let sessionId: String
let command: String
let args: [String]?
let environment: [String: String]?
}
/// Command execution response
struct CommandResponse: Codable {
let sessionId: String
let output: String?
let error: String?
let exitCode: Int32?
let timestamp: Date
}
/// Session information
struct SessionInfo: Codable {
let id: String
let createdAt: Date
let lastActivity: Date
let isActive: Bool
}
/// List sessions response
struct ListSessionsResponse: Codable {
let sessions: [SessionInfo]
}

View file

@ -0,0 +1,90 @@
import Foundation
/// Represents the available update channels for the application.
///
/// This enum defines the different update channels that users can choose from,
/// allowing them to receive either stable releases only or include pre-release versions.
public enum UpdateChannel: String, CaseIterable, Codable, Sendable {
case stable
case prerelease
/// Human-readable display name for the update channel
public var displayName: String {
switch self {
case .stable:
"Stable Only"
case .prerelease:
"Include Pre-releases"
}
}
/// Detailed description of what each channel includes
public var description: String {
switch self {
case .stable:
"Receive only stable, production-ready releases"
case .prerelease:
"Receive both stable releases and beta/pre-release versions"
}
}
/// The Sparkle appcast URL for this update channel
public var appcastURL: URL {
switch self {
case .stable:
URL(string: "https://vibetunnel.sh/appcast.xml")!
case .prerelease:
URL(string: "https://vibetunnel.sh/appcast-prerelease.xml")!
}
}
/// Whether this channel includes pre-release versions
public var includesPreReleases: Bool {
switch self {
case .stable:
false
case .prerelease:
true
}
}
/// The current update channel based on user defaults
public static var current: UpdateChannel {
if let rawValue = UserDefaults.standard.string(forKey: "updateChannel"),
let channel = UpdateChannel(rawValue: rawValue) {
return channel
}
return defaultChannel
}
/// The default update channel based on the current app version
public static var defaultChannel: UpdateChannel {
defaultChannel(for: Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0")
}
/// Determines if the current app version suggests this channel should be default
public static func defaultChannel(for appVersion: String) -> UpdateChannel {
// First check if this build was marked as a pre-release during build time
if let isPrereleaseValue = Bundle.main.object(forInfoDictionaryKey: "IS_PRERELEASE_BUILD"),
let isPrerelease = isPrereleaseValue as? Bool,
isPrerelease {
return .prerelease
}
// Otherwise, check if the version string contains pre-release keywords
let prereleaseKeywords = ["beta", "alpha", "rc", "pre", "dev"]
let lowercaseVersion = appVersion.lowercased()
for keyword in prereleaseKeywords where lowercaseVersion.contains(keyword) {
return .prerelease
}
return .stable
}
}
// MARK: - Identifiable Conformance
extension UpdateChannel: Identifiable {
public var id: String { rawValue }
}

View file

@ -0,0 +1,107 @@
//
// AuthenticationMiddleware.swift
// VibeTunnel
//
// Created by VibeTunnel on 15.06.25.
//
import Foundation
import Hummingbird
import HummingbirdCore
import Logging
import CryptoKit
/// Simple authentication middleware for the tunnel server
struct AuthenticationMiddleware: RouterMiddleware {
private let logger = Logger(label: "VibeTunnel.AuthMiddleware")
private let apiKeyHeader = "X-API-Key"
private let bearerPrefix = "Bearer "
// In production, this should be stored securely and configurable
private let validApiKeys: Set<String>
init() {
// Generate a default API key for development
// In production, this should be configurable via settings
let defaultKey = Self.generateAPIKey()
self.validApiKeys = [defaultKey]
logger.info("Authentication initialized. Default API key: \(defaultKey)")
}
init(apiKeys: Set<String>) {
self.validApiKeys = apiKeys
}
func handle(_ request: Request, context: Context, next: (Request, Context) async throws -> Response) async throws -> Response {
// Skip authentication for health check and WebSocket upgrade
if request.uri.path == "/health" || request.headers[.upgrade] == "websocket" {
return try await next(request, context)
}
// Check for API key in header
if let apiKey = request.headers[apiKeyHeader] {
if validApiKeys.contains(apiKey) {
return try await next(request, context)
}
}
// Check for Bearer token
if let authorization = request.headers[.authorization],
authorization.hasPrefix(bearerPrefix) {
let token = String(authorization.dropFirst(bearerPrefix.count))
if validApiKeys.contains(token) {
return try await next(request, context)
}
}
// No valid authentication found
logger.warning("Unauthorized request to \(request.uri.path)")
throw HTTPError(.unauthorized, message: "Invalid or missing API key")
}
/// Generate a secure API key
static func generateAPIKey() -> String {
let randomBytes = SymmetricKey(size: .bits256)
let data = randomBytes.withUnsafeBytes { Data($0) }
return data.base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
}
}
/// Extension to store and retrieve API keys from UserDefaults
extension AuthenticationMiddleware {
static let apiKeyStorageKey = "VibeTunnel.APIKeys"
static func loadStoredAPIKeys() -> Set<String> {
guard let data = UserDefaults.standard.data(forKey: apiKeyStorageKey),
let keys = try? JSONDecoder().decode(Set<String>.self, from: data) else {
// Generate and store a default key if none exists
let defaultKey = generateAPIKey()
let keys = Set([defaultKey])
saveAPIKeys(keys)
return keys
}
return keys
}
static func saveAPIKeys(_ keys: Set<String>) {
if let data = try? JSONEncoder().encode(keys) {
UserDefaults.standard.set(data, forKey: apiKeyStorageKey)
}
}
static func addAPIKey(_ key: String) {
var keys = loadStoredAPIKeys()
keys.insert(key)
saveAPIKeys(keys)
}
static func removeAPIKey(_ key: String) {
var keys = loadStoredAPIKeys()
keys.remove(key)
saveAPIKeys(keys)
}
}

View file

@ -0,0 +1,334 @@
import os
import Sparkle
import UserNotifications
/// Manages the Sparkle auto-update framework integration for VibeTunnel.
///
/// SparkleUpdaterManager provides:
/// - Automatic update checking and installation
/// - Update UI presentation and user interaction
/// - Delegate callbacks for update lifecycle events
/// - Configuration of update channels and behavior
///
/// ## Features
/// - Automatic update checks based on configured interval
/// - Background downloads with gentle reminders
/// - Support for stable and pre-release update channels
/// - Critical update handling
/// - User notification integration
///
/// ## Usage
/// ```swift
/// let updaterManager = SparkleUpdaterManager()
/// updaterManager.checkForUpdates() // Manual check
/// updaterManager.setUpdateChannel(.preRelease) // Switch channels
/// ```
@MainActor
@Observable
public class SparkleUpdaterManager: NSObject, SPUUpdaterDelegate, SPUStandardUserDriverDelegate, UNUserNotificationCenterDelegate {
// MARK: Initialization
private nonisolated static let staticLogger = Logger(subsystem: "com.amantus.vibetunnel", category: "updates")
/// Initializes the updater manager and configures Sparkle
override init() {
super.init()
Self.staticLogger.info("Initializing SparkleUpdaterManager")
// Initialize the updater controller
initializeUpdaterController()
// Set up notification center for gentle reminders
setupNotificationCenter()
// Listen for update channel changes
setupUpdateChannelListener()
Self.staticLogger
.info("SparkleUpdaterManager initialized. Updater controller initialization completed.")
// Only schedule startup update check in release builds
#if !DEBUG
scheduleStartupUpdateCheck()
#endif
}
// MARK: Public
// MARK: Properties
/// The shared singleton instance of the updater manager
static let shared = SparkleUpdaterManager()
/// The Sparkle updater controller instance
private(set) var updaterController: SPUStandardUpdaterController?
/// The logger instance for update events
private let logger = Logger(subsystem: "com.amantus.vibetunnel", category: "updates")
// Track update state
private var updateInProgress = false
private var lastUpdateCheckDate: Date?
private var gentleReminderTimer: Timer?
// MARK: Methods
/// Checks for updates immediately
func checkForUpdates() {
guard let updaterController = updaterController else {
logger.warning("Updater controller not available")
return
}
logger.info("Manual update check initiated")
updaterController.checkForUpdates(nil)
}
/// Configures the update channel and restarts if needed
func setUpdateChannel(_ channel: UpdateChannel) {
// Store the channel preference
UserDefaults.standard.set(channel.rawValue, forKey: "updateChannel")
logger.info("Update channel changed to: \(channel.rawValue)")
// Force a new update check with the new feed
checkForUpdates()
}
// MARK: Private
/// Initializes the Sparkle updater controller
private func initializeUpdaterController() {
updaterController = SPUStandardUpdaterController(
startingUpdater: true,
updaterDelegate: self,
userDriverDelegate: self
)
guard let updater = updaterController?.updater else {
logger.error("Failed to get updater from controller")
return
}
// Configure updater settings
updater.automaticallyChecksForUpdates = true
updater.updateCheckInterval = 60 * 60 // 1 hour
updater.automaticallyDownloadsUpdates = true
logger.info("""
Updater configured:
- Automatic checks: \(updater.automaticallyChecksForUpdates)
- Check interval: \(updater.updateCheckInterval)s
- Auto download: \(updater.automaticallyDownloadsUpdates)
""")
}
/// Sets up the notification center for gentle reminders
private func setupNotificationCenter() {
UNUserNotificationCenter.current().delegate = self
// Request notification permissions
Task {
do {
let granted = try await UNUserNotificationCenter.current()
.requestAuthorization(options: [.alert, .sound])
logger.info("Notification permission granted: \(granted)")
} catch {
logger.error("Failed to request notification permission: \(error.localizedDescription)")
}
}
}
/// Sets up a listener for update channel changes
private func setupUpdateChannelListener() {
// Listen for channel changes via UserDefaults
UserDefaults.standard.addObserver(
self,
forKeyPath: "updateChannel",
options: [.new],
context: nil
)
}
/// Schedules an update check after app startup
private func scheduleStartupUpdateCheck() {
// Check for updates 5 seconds after app launch
DispatchQueue.main.asyncAfter(deadline: .now() + 5) { [weak self] in
self?.checkForUpdatesInBackground()
}
}
/// Checks for updates in the background without UI
private func checkForUpdatesInBackground() {
logger.info("Starting background update check")
lastUpdateCheckDate = Date()
// Sparkle will check in the background when automaticallyChecksForUpdates is true
// We don't need to explicitly call checkForUpdates for background checks
}
/// Shows a gentle reminder notification for available updates
@MainActor
private func showGentleUpdateReminder() {
let content = UNMutableNotificationContent()
content.title = "Update Available"
content.body = "A new version of VibeTunnel is ready to install. Click to update now."
content.sound = .default
let request = UNNotificationRequest(
identifier: "update-reminder",
content: content,
trigger: nil
)
Task {
do {
try await UNUserNotificationCenter.current().add(request)
logger.info("Gentle update reminder shown")
} catch {
logger.error("Failed to show update reminder: \(error.localizedDescription)")
}
}
}
/// Schedules periodic gentle reminders for available updates
private func scheduleGentleReminders() {
// Cancel any existing timer
gentleReminderTimer?.invalidate()
// Schedule reminders every 4 hours
gentleReminderTimer = Timer.scheduledTimer(withTimeInterval: 4 * 60 * 60, repeats: true) {
[weak self] _ in
Task { @MainActor in
self?.showGentleUpdateReminder()
}
}
// Show first reminder after 1 hour
DispatchQueue.main.asyncAfter(deadline: .now() + 3600) { [weak self] in
Task { @MainActor in
self?.showGentleUpdateReminder()
}
}
}
// MARK: - SPUUpdaterDelegate
@objc public nonisolated func updater(_ updater: SPUUpdater, didFinishLoading appcast: SUAppcast) {
Task { @MainActor in
Self.staticLogger.info("Appcast loaded successfully: \(appcast.items.count) items")
}
}
@objc public nonisolated func updaterDidNotFindUpdate(_ updater: SPUUpdater, error: Error) {
Task { @MainActor in
Self.staticLogger.info("No update found: \(error.localizedDescription)")
}
}
@objc public nonisolated func updater(_ updater: SPUUpdater, didAbortWithError error: Error) {
Task { @MainActor in
Self.staticLogger.error("Update aborted with error: \(error.localizedDescription)")
}
}
// Provide the feed URL dynamically based on update channel
@objc public nonisolated func feedURLString(for updater: SPUUpdater) -> String? {
return UpdateChannel.current.appcastURL.absoluteString
}
// MARK: - SPUStandardUserDriverDelegate
@objc public nonisolated func standardUserDriverWillHandleShowingUpdate(
_ handleShowingUpdate: Bool,
forUpdate update: SUAppcastItem,
state: SPUUserUpdateState
) {
Task { @MainActor in
Self.staticLogger.info("""
Will show update:
- Version: \(update.displayVersionString)
- Critical: \(update.isCriticalUpdate)
- Stage: \(state.stage.rawValue)
""")
}
}
@objc public func standardUserDriverDidReceiveUserAttention(forUpdate update: SUAppcastItem) {
logger.info("User gave attention to update: \(update.displayVersionString)")
updateInProgress = true
// Cancel gentle reminders since user is aware
gentleReminderTimer?.invalidate()
gentleReminderTimer = nil
}
@objc public func standardUserDriverWillFinishUpdateSession() {
logger.info("Update session finishing")
updateInProgress = false
}
// MARK: - Background update handling
@objc public func updater(
_ updater: SPUUpdater,
willDownloadUpdate item: SUAppcastItem,
with request: NSMutableURLRequest
) {
logger.info("Will download update: \(item.displayVersionString)")
}
@objc public func updater(_ updater: SPUUpdater, didDownloadUpdate item: SUAppcastItem) {
logger.info("Update downloaded: \(item.displayVersionString)")
// For background downloads, schedule gentle reminders
if !updateInProgress {
scheduleGentleReminders()
}
}
@objc public func updater(
_ updater: SPUUpdater,
willInstallUpdate item: SUAppcastItem
) {
logger.info("Will install update: \(item.displayVersionString)")
}
// MARK: - UNUserNotificationCenterDelegate
@objc public func userNotificationCenter(
_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse,
withCompletionHandler completionHandler: @escaping () -> Void
) {
if response.notification.request.identifier == "update-reminder" {
logger.info("User clicked update reminder notification")
// Trigger the update UI
checkForUpdates()
}
completionHandler()
}
// MARK: - KVO
public override func observeValue(
forKeyPath keyPath: String?,
of object: Any?,
change: [NSKeyValueChangeKey: Any]?,
context: UnsafeMutableRawPointer?
) {
if keyPath == "updateChannel" {
logger.info("Update channel changed via UserDefaults")
setUpdateChannel(UpdateChannel.current)
}
}
// MARK: - Cleanup
deinit {
UserDefaults.standard.removeObserver(self, forKeyPath: "updateChannel")
// Timer is cleaned up automatically when the object is deallocated
}
}

View file

@ -0,0 +1,41 @@
import Foundation
import ServiceManagement
import os
/// Protocol defining the interface for managing launch at login functionality.
@MainActor
public protocol StartupControlling: Sendable {
func setLaunchAtLogin(enabled: Bool)
var isLaunchAtLoginEnabled: Bool { get }
}
/// Default implementation of startup management using ServiceManagement framework.
///
/// This struct handles:
/// - Enabling/disabling launch at login
/// - Checking current launch at login status
/// - Integration with macOS ServiceManagement APIs
@MainActor
public struct StartupManager: StartupControlling {
private let logger = Logger(subsystem: "com.amantus.vibetunnel", category: "startup")
public init() {}
public func setLaunchAtLogin(enabled: Bool) {
do {
if enabled {
try SMAppService.mainApp.register()
logger.info("Successfully registered for launch at login.")
} else {
try SMAppService.mainApp.unregister()
logger.info("Successfully unregistered for launch at login.")
}
} catch {
logger.error("Failed to \(enabled ? "register" : "unregister") for launch at login: \(error.localizedDescription)")
}
}
public var isLaunchAtLoginEnabled: Bool {
SMAppService.mainApp.status == .enabled
}
}

View file

@ -0,0 +1,163 @@
//
// TerminalManager.swift
// VibeTunnel
//
// Created by VibeTunnel on 15.06.25.
//
import Foundation
import Logging
import Combine
/// Manages terminal sessions and command execution
actor TerminalManager {
private var sessions: [UUID: TunnelSession] = [:]
private var processes: [UUID: Process] = [:]
private var pipes: [UUID: (stdin: Pipe, stdout: Pipe, stderr: Pipe)] = [:]
private let logger = Logger(label: "VibeTunnel.TerminalManager")
/// Create a new terminal session
func createSession(request: CreateSessionRequest) throws -> TunnelSession {
let session = TunnelSession()
sessions[session.id] = session
// Set up process and pipes
let process = Process()
let stdinPipe = Pipe()
let stdoutPipe = Pipe()
let stderrPipe = Pipe()
// Configure the process
process.executableURL = URL(fileURLWithPath: request.shell ?? "/bin/zsh")
process.standardInput = stdinPipe
process.standardOutput = stdoutPipe
process.standardError = stderrPipe
if let workingDirectory = request.workingDirectory {
process.currentDirectoryURL = URL(fileURLWithPath: workingDirectory)
}
if let environment = request.environment {
process.environment = ProcessInfo.processInfo.environment.merging(environment) { _, new in new }
}
// Start the process
do {
try process.run()
processes[session.id] = process
pipes[session.id] = (stdinPipe, stdoutPipe, stderrPipe)
logger.info("Created session \(session.id) with process \(process.processIdentifier)")
} catch {
sessions.removeValue(forKey: session.id)
throw error
}
return session
}
/// Execute a command in a session
func executeCommand(sessionId: UUID, command: String) async throws -> (output: String, error: String) {
guard var session = sessions[sessionId],
let process = processes[sessionId],
let (stdin, stdout, stderr) = pipes[sessionId],
process.isRunning else {
throw TunnelError.sessionNotFound
}
// Update session activity
session.updateActivity()
sessions[sessionId] = session
// Send command to stdin
let commandData = (command + "\n").data(using: .utf8)!
stdin.fileHandleForWriting.write(commandData)
// Read output with timeout
let outputData = try await withTimeout(seconds: 5) {
stdout.fileHandleForReading.availableData
}
let errorData = try await withTimeout(seconds: 0.1) {
stderr.fileHandleForReading.availableData
}
let output = String(data: outputData, encoding: .utf8) ?? ""
let error = String(data: errorData, encoding: .utf8) ?? ""
return (output, error)
}
/// Get all active sessions
func listSessions() -> [TunnelSession] {
return Array(sessions.values)
}
/// Get a specific session
func getSession(id: UUID) -> TunnelSession? {
return sessions[id]
}
/// Close a session
func closeSession(id: UUID) {
if let process = processes[id] {
process.terminate()
processes.removeValue(forKey: id)
}
pipes.removeValue(forKey: id)
sessions.removeValue(forKey: id)
logger.info("Closed session \(id)")
}
/// Clean up inactive sessions
func cleanupInactiveSessions(olderThan minutes: Int = 30) {
let cutoffDate = Date().addingTimeInterval(-Double(minutes * 60))
for (id, session) in sessions {
if session.lastActivity < cutoffDate {
closeSession(id: id)
logger.info("Cleaned up inactive session \(id)")
}
}
}
// Helper function for timeout
private func withTimeout<T>(seconds: TimeInterval, operation: @escaping () async throws -> T) async throws -> T {
try await withThrowingTaskGroup(of: T.self) { group in
group.addTask {
try await operation()
}
group.addTask {
try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000))
throw TunnelError.timeout
}
let result = try await group.next()!
group.cancelAll()
return result
}
}
}
/// Errors that can occur in tunnel operations
enum TunnelError: LocalizedError {
case sessionNotFound
case commandExecutionFailed(String)
case timeout
case invalidRequest
var errorDescription: String? {
switch self {
case .sessionNotFound:
return "Session not found"
case .commandExecutionFailed(let message):
return "Command execution failed: \(message)"
case .timeout:
return "Operation timed out"
case .invalidRequest:
return "Invalid request"
}
}
}

View file

@ -0,0 +1,271 @@
//
// TunnelClient.swift
// VibeTunnel
//
// Created by VibeTunnel on 15.06.25.
//
import Foundation
import Combine
/// Client SDK for interacting with the VibeTunnel server
public class TunnelClient {
private let baseURL: URL
private let apiKey: String
private var session: URLSession
private let decoder = JSONDecoder()
private let encoder = JSONEncoder()
public init(baseURL: URL = URL(string: "http://localhost:8080")!, apiKey: String) {
self.baseURL = baseURL
self.apiKey = apiKey
let config = URLSessionConfiguration.default
config.httpAdditionalHeaders = ["X-API-Key": apiKey]
self.session = URLSession(configuration: config)
decoder.dateDecodingStrategy = .iso8601
encoder.dateEncodingStrategy = .iso8601
}
// MARK: - Health Check
public func checkHealth() async throws -> Bool {
let url = baseURL.appendingPathComponent("health")
let (_, response) = try await session.data(from: url)
guard let httpResponse = response as? HTTPURLResponse else {
throw TunnelClientError.invalidResponse
}
return httpResponse.statusCode == 200
}
// MARK: - Session Management
public func createSession(workingDirectory: String? = nil,
environment: [String: String]? = nil,
shell: String? = nil) async throws -> CreateSessionResponse {
let url = baseURL.appendingPathComponent("sessions")
let request = CreateSessionRequest(
workingDirectory: workingDirectory,
environment: environment,
shell: shell
)
return try await post(to: url, body: request)
}
public func listSessions() async throws -> [SessionInfo] {
let url = baseURL.appendingPathComponent("sessions")
let response: ListSessionsResponse = try await get(from: url)
return response.sessions
}
public func getSession(id: String) async throws -> SessionInfo {
let url = baseURL.appendingPathComponent("sessions/\(id)")
return try await get(from: url)
}
public func closeSession(id: String) async throws {
let url = baseURL.appendingPathComponent("sessions/\(id)")
try await delete(from: url)
}
// MARK: - Command Execution
public func executeCommand(sessionId: String, command: String, args: [String]? = nil) async throws -> CommandResponse {
let url = baseURL.appendingPathComponent("execute")
let request = CommandRequest(
sessionId: sessionId,
command: command,
args: args,
environment: nil
)
return try await post(to: url, body: request)
}
// MARK: - WebSocket Connection
public func connectWebSocket(sessionId: String? = nil) -> TunnelWebSocketClient {
var wsURL = baseURL
wsURL.scheme = wsURL.scheme == "https" ? "wss" : "ws"
wsURL = wsURL.appendingPathComponent("ws/terminal")
return TunnelWebSocketClient(url: wsURL, apiKey: apiKey, sessionId: sessionId)
}
// MARK: - Private Helpers
private func get<T: Decodable>(from url: URL) async throws -> T {
let (data, response) = try await session.data(from: url)
guard let httpResponse = response as? HTTPURLResponse else {
throw TunnelClientError.invalidResponse
}
guard httpResponse.statusCode == 200 else {
throw TunnelClientError.httpError(statusCode: httpResponse.statusCode)
}
return try decoder.decode(T.self, from: data)
}
private func post<T: Encodable, R: Decodable>(to url: URL, body: T) async throws -> R {
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try encoder.encode(body)
let (data, response) = try await session.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw TunnelClientError.invalidResponse
}
guard (200...299).contains(httpResponse.statusCode) else {
throw TunnelClientError.httpError(statusCode: httpResponse.statusCode)
}
return try decoder.decode(R.self, from: data)
}
private func delete(from url: URL) async throws {
var request = URLRequest(url: url)
request.httpMethod = "DELETE"
let (_, response) = try await session.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw TunnelClientError.invalidResponse
}
guard httpResponse.statusCode == 204 else {
throw TunnelClientError.httpError(statusCode: httpResponse.statusCode)
}
}
}
/// WebSocket client for real-time terminal communication
public class TunnelWebSocketClient: NSObject {
private let url: URL
private let apiKey: String
private var sessionId: String?
private var webSocketTask: URLSessionWebSocketTask?
private let messageSubject = PassthroughSubject<WSMessage, Never>()
public var messages: AnyPublisher<WSMessage, Never> {
messageSubject.eraseToAnyPublisher()
}
init(url: URL, apiKey: String, sessionId: String? = nil) {
self.url = url
self.apiKey = apiKey
self.sessionId = sessionId
super.init()
}
public func connect() {
let session = URLSession(configuration: .default, delegate: self, delegateQueue: nil)
var request = URLRequest(url: url)
request.setValue(apiKey, forHTTPHeaderField: "X-API-Key")
webSocketTask = session.webSocketTask(with: request)
webSocketTask?.resume()
// Send initial connection message if session ID is provided
if let sessionId = sessionId {
send(WSMessage(type: .connect, sessionId: sessionId))
}
// Start receiving messages
receiveMessage()
}
public func send(_ message: WSMessage) {
guard let webSocketTask = webSocketTask else { return }
do {
let data = try JSONEncoder().encode(message)
let text = String(data: data, encoding: .utf8) ?? "{}"
let message = URLSessionWebSocketTask.Message.string(text)
webSocketTask.send(message) { error in
if let error = error {
print("WebSocket send error: \(error)")
}
}
} catch {
print("Failed to encode message: \(error)")
}
}
public func sendCommand(_ command: String) {
guard let sessionId = sessionId else { return }
send(WSMessage(type: .command, sessionId: sessionId, data: command))
}
public func disconnect() {
webSocketTask?.cancel(with: .goingAway, reason: nil)
}
private func receiveMessage() {
webSocketTask?.receive { [weak self] result in
switch result {
case .success(let message):
switch message {
case .string(let text):
if let data = text.data(using: .utf8),
let wsMessage = try? JSONDecoder().decode(WSMessage.self, from: data) {
self?.messageSubject.send(wsMessage)
}
case .data(let data):
if let wsMessage = try? JSONDecoder().decode(WSMessage.self, from: data) {
self?.messageSubject.send(wsMessage)
}
@unknown default:
break
}
// Continue receiving messages
self?.receiveMessage()
case .failure(let error):
print("WebSocket receive error: \(error)")
}
}
}
}
// MARK: - URLSessionWebSocketDelegate
extension TunnelWebSocketClient: URLSessionWebSocketDelegate {
public func urlSession(_ session: URLSession, webSocketTask: URLSessionWebSocketTask, didOpenWithProtocol protocol: String?) {
print("WebSocket connected")
}
public func urlSession(_ session: URLSession, webSocketTask: URLSessionWebSocketTask, didCloseWith closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) {
print("WebSocket disconnected")
messageSubject.send(completion: .finished)
}
}
// MARK: - Errors
public enum TunnelClientError: LocalizedError {
case invalidResponse
case httpError(statusCode: Int)
case decodingError(Error)
public var errorDescription: String? {
switch self {
case .invalidResponse:
return "Invalid response from server"
case .httpError(let statusCode):
return "HTTP error: \(statusCode)"
case .decodingError(let error):
return "Decoding error: \(error.localizedDescription)"
}
}
}

View file

@ -0,0 +1,259 @@
//
// TunnelServer.swift
// VibeTunnel
//
// Created by VibeTunnel on 15.06.25.
//
import Foundation
import AppKit
import Combine
import Logging
import os
import Hummingbird
import HummingbirdCore
import HummingbirdWebSocket
import NIOCore
import NIOHTTP1
/// Main tunnel server implementation using Hummingbird
@MainActor
final class TunnelServer: ObservableObject {
private let port: Int
private let logger = Logger(label: "VibeTunnel.TunnelServer")
private var app: Application<some Router>?
private let terminalManager = TerminalManager()
@Published var isRunning = false
@Published var lastError: Error?
@Published var connectedClients = 0
init(port: Int = 8080) {
self.port = port
}
func start() async throws {
logger.info("Starting tunnel server on port \(port)")
do {
// Build the Hummingbird application
let app = try await buildApplication()
self.app = app
// Start the server
try await app.run()
await MainActor.run {
self.isRunning = true
}
} catch {
await MainActor.run {
self.lastError = error
self.isRunning = false
}
throw error
}
}
func stop() async {
logger.info("Stopping tunnel server")
if let app = app {
await app.stop()
self.app = nil
}
await MainActor.run {
self.isRunning = false
}
}
private func buildApplication() async throws -> Application<some Router> {
// Create router
var router = RouterBuilder()
// Add middleware
router.middlewares.add(LogRequestsMiddleware(logLevel: .info))
router.middlewares.add(CORSMiddleware())
router.middlewares.add(AuthenticationMiddleware(apiKeys: AuthenticationMiddleware.loadStoredAPIKeys()))
// Configure routes
configureRoutes(&router)
// Add WebSocket routes
router.addWebSocketRoutes(terminalManager: terminalManager)
// Create application configuration
let configuration = ApplicationConfiguration(
address: .hostname("127.0.0.1", port: port),
serverName: "VibeTunnel"
)
// Create and configure the application
let app = Application(
router: router.buildRouter(),
configuration: configuration,
logger: logger
)
// Add cleanup task
app.services.add(CleanupService(terminalManager: terminalManager))
return app
}
private func configureRoutes(_ router: inout RouterBuilder) {
// Health check endpoint
router.get("/health") { request, context -> HTTPResponse.Status in
return .ok
}
// Server info endpoint
router.get("/info") { request, context -> [String: Any] in
return [
"name": "VibeTunnel",
"version": Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0.0",
"uptime": ProcessInfo.processInfo.systemUptime,
"sessions": await self.terminalManager.listSessions().count
]
}
// Session management endpoints
router.group("sessions") { sessions in
// List all sessions
sessions.get("/") { request, context -> ListSessionsResponse in
let sessions = await self.terminalManager.listSessions()
let sessionInfos = sessions.map { session in
SessionInfo(
id: session.id.uuidString,
createdAt: session.createdAt,
lastActivity: session.lastActivity,
isActive: session.isActive
)
}
return ListSessionsResponse(sessions: sessionInfos)
}
// Create new session
sessions.post("/") { request, context -> CreateSessionResponse in
let createRequest = try await request.decode(as: CreateSessionRequest.self, context: context)
let session = try await self.terminalManager.createSession(request: createRequest)
return CreateSessionResponse(
sessionId: session.id.uuidString,
createdAt: session.createdAt
)
}
// Get session info
sessions.get(":sessionId") { request, context -> SessionInfo in
guard let sessionIdString = request.parameters.get("sessionId"),
let sessionId = UUID(uuidString: sessionIdString),
let session = await self.terminalManager.getSession(id: sessionId) else {
throw HTTPError(.notFound)
}
return SessionInfo(
id: session.id.uuidString,
createdAt: session.createdAt,
lastActivity: session.lastActivity,
isActive: session.isActive
)
}
// Close session
sessions.delete(":sessionId") { request, context -> HTTPResponse.Status in
guard let sessionIdString = request.parameters.get("sessionId"),
let sessionId = UUID(uuidString: sessionIdString) else {
throw HTTPError(.badRequest)
}
await self.terminalManager.closeSession(id: sessionId)
return .noContent
}
}
// Command execution endpoint
router.post("/execute") { request, context -> CommandResponse in
let commandRequest = try await request.decode(as: CommandRequest.self, context: context)
guard let sessionId = UUID(uuidString: commandRequest.sessionId) else {
throw HTTPError(.badRequest, message: "Invalid session ID")
}
do {
let (output, error) = try await self.terminalManager.executeCommand(
sessionId: sessionId,
command: commandRequest.command
)
return CommandResponse(
sessionId: commandRequest.sessionId,
output: output.isEmpty ? nil : output,
error: error.isEmpty ? nil : error,
exitCode: nil,
timestamp: Date()
)
} catch {
throw HTTPError(.internalServerError, message: error.localizedDescription)
}
}
}
// Service for periodic cleanup
struct CleanupService: Service {
let terminalManager: TerminalManager
func run() async throws {
// Run cleanup every 5 minutes
while !Task.isCancelled {
await terminalManager.cleanupInactiveSessions(olderThan: 30)
try await Task.sleep(nanoseconds: 5 * 60 * 1_000_000_000) // 5 minutes
}
}
}
}
// MARK: - Middleware
/// CORS middleware for browser-based clients
struct CORSMiddleware: RouterMiddleware {
func handle(_ request: Request, context: Context, next: (Request, Context) async throws -> Response) async throws -> Response {
var response = try await next(request, context)
response.headers[.accessControlAllowOrigin] = "*"
response.headers[.accessControlAllowMethods] = "GET, POST, PUT, DELETE, OPTIONS"
response.headers[.accessControlAllowHeaders] = "Content-Type, Authorization"
return response
}
}
// MARK: - Integration with AppDelegate
extension AppDelegate {
func startTunnelServer() {
Task {
do {
let port = UserDefaults.standard.integer(forKey: "serverPort")
let tunnelServer = TunnelServer(port: port > 0 ? port : 8080)
// Store reference if needed
// self.tunnelServer = tunnelServer
try await tunnelServer.start()
} catch {
print("Failed to start tunnel server: \(error)")
// Show error alert
await MainActor.run {
let alert = NSAlert()
alert.messageText = "Failed to Start Server"
alert.informativeText = error.localizedDescription
alert.alertStyle = .critical
alert.runModal()
}
}
}
}
}

View file

@ -0,0 +1,151 @@
//
// TunnelServerDemo.swift
// VibeTunnel
//
// Created by VibeTunnel on 15.06.25.
//
import Foundation
import Combine
/// Demo code showing how to use the VibeTunnel server
class TunnelServerDemo {
static func runDemo() async {
// Get the API key (in production, this should be managed securely)
let apiKeys = AuthenticationMiddleware.loadStoredAPIKeys()
guard let apiKey = apiKeys.first else {
print("No API key found")
return
}
print("Using API key: \(apiKey)")
// Create client
let client = TunnelClient(apiKey: apiKey)
do {
// Check server health
let isHealthy = try await client.checkHealth()
print("Server healthy: \(isHealthy)")
// Create a new session
let session = try await client.createSession(
workingDirectory: "/tmp",
shell: "/bin/zsh"
)
print("Created session: \(session.sessionId)")
// Execute a command
let response = try await client.executeCommand(
sessionId: session.sessionId,
command: "echo 'Hello from VibeTunnel!'"
)
print("Command output: \(response.output ?? "none")")
// List all sessions
let sessions = try await client.listSessions()
print("Active sessions: \(sessions.count)")
// Close the session
try await client.closeSession(id: session.sessionId)
print("Session closed")
} catch {
print("Demo error: \(error)")
}
}
static func runWebSocketDemo() async {
let apiKeys = AuthenticationMiddleware.loadStoredAPIKeys()
guard let apiKey = apiKeys.first else {
print("No API key found")
return
}
let client = TunnelClient(apiKey: apiKey)
do {
// Create a session first
let session = try await client.createSession()
print("Created session for WebSocket: \(session.sessionId)")
// Connect WebSocket
let wsClient = client.connectWebSocket(sessionId: session.sessionId)
wsClient.connect()
// Subscribe to messages
let cancellable = wsClient.messages.sink { message in
switch message.type {
case .output:
print("Output: \(message.data ?? "")")
case .error:
print("Error: \(message.data ?? "")")
default:
print("Message: \(message.type) - \(message.data ?? "")")
}
}
// Send some commands
try await Task.sleep(nanoseconds: 1_000_000_000) // 1 second
wsClient.sendCommand("pwd")
try await Task.sleep(nanoseconds: 1_000_000_000) // 1 second
wsClient.sendCommand("ls -la")
try await Task.sleep(nanoseconds: 2_000_000_000) // 2 seconds
// Disconnect
wsClient.disconnect()
cancellable.cancel()
} catch {
print("WebSocket demo error: \(error)")
}
}
}
// MARK: - cURL Examples
/*
Here are some example cURL commands to test the server:
# Set your API key
export API_KEY="your-api-key-here"
# Health check (no auth required)
curl http://localhost:8080/health
# Get server info
curl -H "X-API-Key: $API_KEY" http://localhost:8080/info
# Create a new session
curl -X POST http://localhost:8080/sessions \
-H "X-API-Key: $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"workingDirectory": "/tmp",
"shell": "/bin/zsh"
}'
# List all sessions
curl -H "X-API-Key: $API_KEY" http://localhost:8080/sessions
# Execute a command
curl -X POST http://localhost:8080/execute \
-H "X-API-Key: $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"sessionId": "your-session-id",
"command": "ls -la"
}'
# Get session info
curl -H "X-API-Key: $API_KEY" http://localhost:8080/sessions/your-session-id
# Close a session
curl -X DELETE -H "X-API-Key: $API_KEY" http://localhost:8080/sessions/your-session-id
# WebSocket connection (using websocat tool)
websocat -H "X-API-Key: $API_KEY" ws://localhost:8080/ws/terminal
*/

View file

@ -0,0 +1,196 @@
//
// WebSocketHandler.swift
// VibeTunnel
//
// Created by VibeTunnel on 15.06.25.
//
import Foundation
import Hummingbird
import HummingbirdCore
import NIOCore
import NIOWebSocket
import Logging
/// WebSocket message types for terminal communication
enum WSMessageType: String, Codable {
case connect = "connect"
case command = "command"
case output = "output"
case error = "error"
case ping = "ping"
case pong = "pong"
case close = "close"
}
/// WebSocket message structure
struct WSMessage: Codable {
let type: WSMessageType
let sessionId: String?
let data: String?
let timestamp: Date
init(type: WSMessageType, sessionId: String? = nil, data: String? = nil) {
self.type = type
self.sessionId = sessionId
self.data = data
self.timestamp = Date()
}
}
/// Handles WebSocket connections for real-time terminal communication
final class WebSocketHandler {
private let terminalManager: TerminalManager
private let logger = Logger(label: "VibeTunnel.WebSocketHandler")
private var activeConnections: [UUID: WebSocketHandler.Connection] = [:]
init(terminalManager: TerminalManager) {
self.terminalManager = terminalManager
}
/// Handle incoming WebSocket connection
func handle(ws: WebSocket, context: some RequestContext) async {
let connectionId = UUID()
let connection = Connection(id: connectionId, websocket: ws)
await MainActor.run {
activeConnections[connectionId] = connection
}
logger.info("WebSocket connection established: \(connectionId)")
// Set up message handlers
ws.onText { [weak self] ws, text in
await self?.handleTextMessage(text, connection: connection)
}
ws.onBinary { [weak self] ws, buffer in
// Handle binary data if needed
self?.logger.debug("Received binary data: \(buffer.readableBytes) bytes")
}
ws.onClose { [weak self] closeCode in
await self?.handleClose(connection: connection)
}
// Send initial connection acknowledgment
await sendMessage(WSMessage(type: .connect, data: "Connected to VibeTunnel"), to: connection)
// Keep connection alive with periodic pings
Task {
while !Task.isCancelled && !connection.isClosed {
await sendMessage(WSMessage(type: .ping), to: connection)
try? await Task.sleep(nanoseconds: 30 * 1_000_000_000) // 30 seconds
}
}
}
private func handleTextMessage(_ text: String, connection: Connection) async {
guard let data = text.data(using: .utf8),
let message = try? JSONDecoder().decode(WSMessage.self, from: data) else {
logger.error("Failed to decode WebSocket message: \(text)")
await sendError("Invalid message format", to: connection)
return
}
switch message.type {
case .connect:
// Handle session connection
if let sessionId = message.sessionId,
let uuid = UUID(uuidString: sessionId) {
connection.sessionId = uuid
await sendMessage(WSMessage(type: .output, sessionId: sessionId, data: "Session connected"), to: connection)
}
case .command:
// Execute command in terminal session
guard let sessionId = connection.sessionId,
let command = message.data else {
await sendError("Session ID and command required", to: connection)
return
}
do {
let (output, error) = try await terminalManager.executeCommand(sessionId: sessionId, command: command)
if !output.isEmpty {
await sendMessage(WSMessage(type: .output, sessionId: sessionId.uuidString, data: output), to: connection)
}
if !error.isEmpty {
await sendMessage(WSMessage(type: .error, sessionId: sessionId.uuidString, data: error), to: connection)
}
} catch {
await sendError(error.localizedDescription, to: connection)
}
case .ping:
// Respond to ping with pong
await sendMessage(WSMessage(type: .pong), to: connection)
case .close:
// Close the session
if let sessionId = connection.sessionId {
await terminalManager.closeSession(id: sessionId)
}
try? await connection.websocket.close()
default:
logger.warning("Unhandled message type: \(message.type)")
}
}
private func handleClose(connection: Connection) async {
logger.info("WebSocket connection closed: \(connection.id)")
await MainActor.run {
activeConnections.removeValue(forKey: connection.id)
}
// Clean up associated session if any
if let sessionId = connection.sessionId {
await terminalManager.closeSession(id: sessionId)
}
connection.isClosed = true
}
private func sendMessage(_ message: WSMessage, to connection: Connection) async {
do {
let data = try JSONEncoder().encode(message)
let text = String(data: data, encoding: .utf8) ?? "{}"
try await connection.websocket.send(text: text)
} catch {
logger.error("Failed to send WebSocket message: \(error)")
}
}
private func sendError(_ error: String, to connection: Connection) async {
await sendMessage(WSMessage(type: .error, data: error), to: connection)
}
/// WebSocket connection wrapper
class Connection {
let id: UUID
let websocket: WebSocket
var sessionId: UUID?
var isClosed = false
init(id: UUID, websocket: WebSocket) {
self.id = id
self.websocket = websocket
}
}
}
/// Extension to add WebSocket routes to the router
extension RouterBuilder {
mutating func addWebSocketRoutes(terminalManager: TerminalManager) {
let wsHandler = WebSocketHandler(terminalManager: terminalManager)
// WebSocket endpoint for terminal streaming
ws("/ws/terminal") { request, ws, context in
await wsHandler.handle(ws: ws, context: context)
}
}
}

61
VibeTunnel/Info.plist Normal file
View file

@ -0,0 +1,61 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIconFile</key>
<string></string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>LSMinimumSystemVersion</key>
<string>$(MACOSX_DEPLOYMENT_TARGET)</string>
<key>NSHumanReadableCopyright</key>
<string>Copyright © 2024 Amantus AI. All rights reserved.</string>
<key>NSMainStoryboardFile</key>
<string>Main</string>
<key>NSPrincipalClass</key>
<string>NSApplication</string>
<key>NSSupportsAutomaticTermination</key>
<true/>
<key>LSUIElement</key>
<true/>
<key>NSSupportsSuddenTermination</key>
<true/>
<key>SUFeedURL</key>
<string>https://vibetunnel.sh/appcast.xml</string>
<key>SUPublicEDKey</key>
<string>YOUR_PUBLIC_ED_KEY_HERE</string>
<key>SUEnableAutomaticChecks</key>
<true/>
<key>SUScheduledCheckInterval</key>
<integer>86400</integer>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<false/>
<key>NSExceptionDomains</key>
<dict>
<key>vibetunnel.sh</key>
<dict>
<key>NSExceptionAllowsInsecureHTTPLoads</key>
<false/>
<key>NSIncludesSubdomains</key>
<true/>
</dict>
</dict>
</dict>
</dict>
</plist>

BIN
VibeTunnel/Presentation/.DS_Store vendored Normal file

Binary file not shown.

View file

@ -0,0 +1,186 @@
import SwiftUI
// MARK: - Material Background Modifier
/// Applies a rounded rectangle background with material fill.
struct MaterialBackgroundModifier: ViewModifier {
let cornerRadius: CGFloat
let material: Material
init(cornerRadius: CGFloat = 10, material: Material = .thickMaterial) {
self.cornerRadius = cornerRadius
self.material = material
}
func body(content: Content) -> some View {
content
.background(
RoundedRectangle(cornerRadius: cornerRadius)
.fill(material))
}
}
// MARK: - Standard Padding Modifier
/// Applies standard horizontal and vertical padding used throughout the app.
struct StandardPaddingModifier: ViewModifier {
let horizontal: CGFloat
let vertical: CGFloat
init(horizontal: CGFloat = 16, vertical: CGFloat = 14) {
self.horizontal = horizontal
self.vertical = vertical
}
func body(content: Content) -> some View {
content
.padding(.horizontal, horizontal)
.padding(.vertical, vertical)
}
}
// MARK: - Card Style Modifier
/// Combines material background with standard padding for card-like components.
struct CardStyleModifier: ViewModifier {
let cornerRadius: CGFloat
let horizontalPadding: CGFloat
let verticalPadding: CGFloat
init(
cornerRadius: CGFloat = 10,
horizontalPadding: CGFloat = 14,
verticalPadding: CGFloat = 10) {
self.cornerRadius = cornerRadius
self.horizontalPadding = horizontalPadding
self.verticalPadding = verticalPadding
}
func body(content: Content) -> some View {
content
.padding(.horizontal, horizontalPadding)
.padding(.vertical, verticalPadding)
.materialBackground(cornerRadius: cornerRadius)
}
}
// MARK: - View Extensions
public extension View {
/// Applies a material background with rounded corners.
///
/// - Parameters:
/// - cornerRadius: Corner radius for the rounded rectangle (default: 10)
/// - material: Material type to use (default: .thickMaterial)
func materialBackground(
cornerRadius: CGFloat = 10,
material: Material = .thickMaterial) -> some View {
modifier(MaterialBackgroundModifier(cornerRadius: cornerRadius, material: material))
}
/// Applies standard padding used throughout the app.
///
/// - Parameters:
/// - horizontal: Horizontal padding (default: 16)
/// - vertical: Vertical padding (default: 14)
func standardPadding(
horizontal: CGFloat = 16,
vertical: CGFloat = 14) -> some View {
modifier(StandardPaddingModifier(horizontal: horizontal, vertical: vertical))
}
/// Applies card styling with material background and padding.
///
/// - Parameters:
/// - cornerRadius: Corner radius for the card (default: 10)
/// - horizontalPadding: Horizontal padding (default: 14)
/// - verticalPadding: Vertical padding (default: 10)
func cardStyle(
cornerRadius: CGFloat = 10,
horizontalPadding: CGFloat = 14,
verticalPadding: CGFloat = 10) -> some View {
modifier(CardStyleModifier(
cornerRadius: cornerRadius,
horizontalPadding: horizontalPadding,
verticalPadding: verticalPadding))
}
}
// MARK: - Previews
#Preview("Material Backgrounds") {
VStack(spacing: 20) {
Text("Thick Material")
.standardPadding()
.materialBackground(material: .thickMaterial)
Text("Regular Material")
.standardPadding()
.materialBackground(material: .regular)
Text("Thin Material")
.standardPadding()
.materialBackground(material: .thin)
Text("Ultra Thin Material")
.standardPadding()
.materialBackground(material: .ultraThin)
}
.padding()
.frame(width: 300)
.background(Color(NSColor.windowBackgroundColor))
}
#Preview("Card Styles") {
VStack(spacing: 16) {
VStack(alignment: .leading, spacing: 8) {
Text("Default Card Style")
.font(.headline)
Text("With standard padding and corner radius")
.font(.caption)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, alignment: .leading)
.cardStyle()
VStack(alignment: .leading, spacing: 8) {
Text("Custom Card Style")
.font(.headline)
Text("With larger padding and corner radius")
.font(.caption)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, alignment: .leading)
.cardStyle(cornerRadius: 16, horizontalPadding: 20, verticalPadding: 16)
}
.padding()
.frame(width: 350)
.background(Color(NSColor.windowBackgroundColor))
}
#Preview("Standard Padding") {
VStack(spacing: 16) {
HStack {
Text("Default Padding")
Spacer()
Text("16pt H, 14pt V")
.font(.caption)
.foregroundStyle(.secondary)
}
.standardPadding()
.background(Color.blue.opacity(0.1))
HStack {
Text("Custom Padding")
Spacer()
Text("24pt H, 20pt V")
.font(.caption)
.foregroundStyle(.secondary)
}
.standardPadding(horizontal: 24, vertical: 20)
.background(Color.green.opacity(0.1))
}
.padding()
.frame(width: 400)
.background(Color(NSColor.windowBackgroundColor))
}

View file

@ -0,0 +1,59 @@
import SwiftUI
extension View {
func pressEvents(onPress: @escaping () -> Void, onRelease: @escaping () -> Void) -> some View {
modifier(PressEventModifier(onPress: onPress, onRelease: onRelease))
}
func pointingHandCursor() -> some View {
modifier(PointingHandCursorModifier())
}
}
/// View modifier for handling press events on buttons.
struct PressEventModifier: ViewModifier {
let onPress: () -> Void
let onRelease: () -> Void
func body(content: Content) -> some View {
content
.simultaneousGesture(
DragGesture(minimumDistance: 0)
.onChanged { _ in onPress() }
.onEnded { _ in onRelease() })
}
}
/// View modifier for showing pointing hand cursor on hover.
struct PointingHandCursorModifier: ViewModifier {
func body(content: Content) -> some View {
content
.background(
CursorTrackingView()
.allowsHitTesting(false))
}
}
/// NSViewRepresentable that handles cursor changes properly
struct CursorTrackingView: NSViewRepresentable {
func makeNSView(context _: Context) -> CursorTrackingNSView {
CursorTrackingNSView()
}
func updateNSView(_: CursorTrackingNSView, context _: Context) {
// No updates needed
}
}
/// Custom NSView that properly handles cursor tracking
class CursorTrackingNSView: NSView {
override func resetCursorRects() {
super.resetCursorRects()
addCursorRect(bounds, cursor: .pointingHand)
}
override func viewDidMoveToWindow() {
super.viewDidMoveToWindow()
window?.invalidateCursorRects(for: self)
}
}

View file

@ -0,0 +1,180 @@
import AppKit
import SwiftUI
/// About view displaying application information, version details, and credits.
///
/// This view provides information about VibeTunnel including version numbers,
/// build details, developer credits, and links to external resources like
/// GitHub repository and support channels.
struct AboutView: View {
var appName: String {
Bundle.main.infoDictionary?["CFBundleDisplayName"] as? String ??
Bundle.main.infoDictionary?["CFBundleName"] as? String ?? "VibeTunnel"
}
var appVersion: String {
let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unknown"
let build = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "Unknown"
return "\(version) (\(build))"
}
var body: some View {
NavigationStack {
ScrollView {
VStack(spacing: 24) {
appInfoSection
descriptionSection
linksSection
Spacer(minLength: 40)
copyrightSection
}
.frame(maxWidth: .infinity)
.standardPadding()
}
.scrollContentBackground(.hidden)
.navigationTitle("About \(appName)")
}
}
private var appInfoSection: some View {
VStack(spacing: 16) {
InteractiveAppIcon()
Text(appName)
.font(.largeTitle)
.fontWeight(.medium)
Text("Version \(appVersion)")
.font(.footnote)
.foregroundStyle(.secondary)
}
.padding(.top, 20)
}
private var descriptionSection: some View {
Text("Connect to AI providers with a unified interface")
.font(.body)
.foregroundStyle(.secondary)
}
private var linksSection: some View {
VStack(spacing: 12) {
HoverableLink(url: "https://github.com/amantus-ai/vibetunnel", title: "View on GitHub", icon: "link")
HoverableLink(
url: "https://github.com/amantus-ai/vibetunnel/issues",
title: "Report an Issue",
icon: "exclamationmark.bubble")
HoverableLink(url: "https://x.com/steipete", title: "Follow @steipete on Twitter", icon: "bird")
}
}
private var copyrightSection: some View {
Text("© 2025 Amantus AI • MIT Licensed")
.font(.footnote)
.foregroundStyle(.secondary)
.padding(.bottom, 32)
}
}
/// Hoverable link component with underline animation.
///
/// This component displays a link with an icon that shows an underline on hover
/// and changes the cursor to a pointing hand for better user experience.
struct HoverableLink: View {
let url: String
let title: String
let icon: String
@State
private var isHovering = false
var body: some View {
Link(destination: URL(string: url)!) {
Label(title, systemImage: icon)
.underline(isHovering, color: .accentColor)
}
.buttonStyle(.link)
.pointingHandCursor()
.onHover { hovering in
withAnimation(.easeInOut(duration: 0.2)) {
isHovering = hovering
}
}
}
}
/// Interactive app icon component with shadow effects and website link.
///
/// This component displays the VibeTunnel app icon with dynamic shadow effects that respond
/// to user interaction. It includes hover effects for visual feedback and opens the
/// VibeTunnel website when clicked.
struct InteractiveAppIcon: View {
@State
private var isHovering = false
@State
private var isPressed = false
@Environment(\.colorScheme)
private var colorScheme
var body: some View {
ZStack {
Image(nsImage: NSApp.applicationIconImage)
.resizable()
.frame(width: 128, height: 128)
.clipShape(RoundedRectangle(cornerRadius: 22))
.scaleEffect(isPressed ? 0.95 : (isHovering ? 1.05 : 1.0))
.shadow(
color: shadowColor,
radius: shadowRadius,
x: 0,
y: shadowOffset)
.animation(.easeInOut(duration: 0.2), value: isHovering)
.animation(.easeInOut(duration: 0.1), value: isPressed)
// Invisible button overlay for click handling
Button(action: openWebsite) {
Rectangle()
.fill(Color.clear)
.frame(width: 128, height: 128)
}
.buttonStyle(PlainButtonStyle())
}
.pointingHandCursor()
.onHover { hovering in
isHovering = hovering
}
.pressEvents(
onPress: { isPressed = true },
onRelease: { isPressed = false })
}
private var shadowColor: Color {
if colorScheme == .dark {
.black.opacity(isHovering ? 0.6 : 0.4)
} else {
.black.opacity(isHovering ? 0.3 : 0.2)
}
}
private var shadowRadius: CGFloat {
isHovering ? 20 : 12
}
private var shadowOffset: CGFloat {
isHovering ? 8 : 4
}
private func openWebsite() {
guard let url = URL(string: "https://vibetunnel.ai") else { return }
NSWorkspace.shared.open(url)
}
}
// MARK: - Preview
#Preview("About View") {
AboutView()
.frame(width: 570, height: 600)
}

View file

@ -0,0 +1,279 @@
//
// SettingsView.swift
// VibeTunnel
//
// Created by Peter Steinberger on 15.06.25.
//
import SwiftUI
struct SettingsView: View {
var body: some View {
TabView {
GeneralSettingsView()
.tabItem {
Label("General", systemImage: "gear")
}
AdvancedSettingsView()
.tabItem {
Label("Advanced", systemImage: "gearshape.2")
}
}
.frame(minWidth: 600, idealWidth: 700, minHeight: 400, idealHeight: 500)
}
}
struct GeneralSettingsView: View {
@AppStorage("autostart") private var autostart = false
@AppStorage("showNotifications") private var showNotifications = true
@AppStorage("showInDock") private var showInDock = false
private let startupManager = StartupManager()
var body: some View {
NavigationStack {
Form {
Section {
// Launch at Login
VStack(alignment: .leading, spacing: 4) {
Toggle("Launch at Login", isOn: launchAtLoginBinding)
Text("Automatically start VibeTunnel when you log into your Mac.")
.font(.caption)
.foregroundStyle(.secondary)
}
// Show Notifications
VStack(alignment: .leading, spacing: 4) {
Toggle("Show notifications", isOn: $showNotifications)
Text("Display notifications for important events.")
.font(.caption)
.foregroundStyle(.secondary)
}
} header: {
Text("Application")
.font(.headline)
}
Section {
// Show in Dock
VStack(alignment: .leading, spacing: 4) {
Toggle("Show in Dock", isOn: showInDockBinding)
Text("Display VibeTunnel in the Dock. When disabled, VibeTunnel runs as a menu bar app only.")
.font(.caption)
.foregroundStyle(.secondary)
}
} header: {
Text("Appearance")
.font(.headline)
}
}
.formStyle(.grouped)
.scrollContentBackground(.hidden)
.navigationTitle("General Settings")
}
.task {
// Sync launch at login status
autostart = startupManager.isLaunchAtLoginEnabled
}
}
private var launchAtLoginBinding: Binding<Bool> {
Binding(
get: { autostart },
set: { newValue in
autostart = newValue
startupManager.setLaunchAtLogin(enabled: newValue)
})
}
private var showInDockBinding: Binding<Bool> {
Binding(
get: { showInDock },
set: { newValue in
showInDock = newValue
NSApp.setActivationPolicy(newValue ? .regular : .accessory)
})
}
}
struct AdvancedSettingsView: View {
@AppStorage("debugMode") private var debugMode = false
@AppStorage("serverPort") private var serverPort = "8080"
@AppStorage("updateChannel") private var updateChannelRaw = UpdateChannel.stable.rawValue
@State private var isCheckingForUpdates = false
@StateObject private var tunnelServer: TunnelServer
init() {
let port = Int(UserDefaults.standard.string(forKey: "serverPort") ?? "8080") ?? 8080
_tunnelServer = StateObject(wrappedValue: TunnelServer(port: port))
}
var updateChannel: UpdateChannel {
UpdateChannel(rawValue: updateChannelRaw) ?? .stable
}
var body: some View {
NavigationStack {
Form {
Section {
// Update Channel
VStack(alignment: .leading, spacing: 4) {
HStack {
Text("Update Channel")
Spacer()
Picker("", selection: updateChannelBinding) {
ForEach(UpdateChannel.allCases) { channel in
Text(channel.displayName).tag(channel)
}
}
.pickerStyle(.menu)
.labelsHidden()
}
Text(updateChannel.description)
.font(.caption)
.foregroundStyle(.secondary)
}
// Check for Updates
HStack {
VStack(alignment: .leading, spacing: 4) {
Text("Check for Updates")
Text("Check for new versions of VibeTunnel")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Button("Check Now") {
checkForUpdates()
}
.buttonStyle(.bordered)
.disabled(isCheckingForUpdates)
}
.padding(.top, 8)
} header: {
Text("Updates")
.font(.headline)
}
Section {
// Tunnel Server
VStack(alignment: .leading, spacing: 8) {
HStack {
VStack(alignment: .leading, spacing: 4) {
HStack {
Text("Tunnel Server")
if tunnelServer.isRunning {
Circle()
.fill(.green)
.frame(width: 8, height: 8)
}
}
Text(tunnelServer.isRunning ? "Server is running on port \(serverPort)" : "Server is stopped")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Button(tunnelServer.isRunning ? "Stop" : "Start") {
toggleServer()
}
.buttonStyle(.bordered)
.tint(tunnelServer.isRunning ? .red : .blue)
}
if tunnelServer.isRunning {
Link("Open in Browser", destination: URL(string: "http://localhost:\(serverPort)")!)
.font(.caption)
}
}
VStack(alignment: .leading, spacing: 4) {
HStack {
Text("Server port:")
TextField("", text: $serverPort)
.frame(width: 80)
.disabled(tunnelServer.isRunning)
}
Text("The port used for the local tunnel server. Restart server to apply changes.")
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(.top, 8)
} header: {
Text("Server")
.font(.headline)
}
Section {
VStack(alignment: .leading, spacing: 4) {
Toggle("Debug mode", isOn: $debugMode)
Text("Enable additional logging and debugging features.")
.font(.caption)
.foregroundStyle(.secondary)
}
} header: {
Text("Advanced")
.font(.headline)
}
}
.formStyle(.grouped)
.scrollContentBackground(.hidden)
.navigationTitle("Advanced Settings")
}
}
private var updateChannelBinding: Binding<UpdateChannel> {
Binding(
get: { updateChannel },
set: { newValue in
updateChannelRaw = newValue.rawValue
// Notify the updater manager about the channel change
NotificationCenter.default.post(
name: Notification.Name("UpdateChannelChanged"),
object: nil,
userInfo: ["channel": newValue]
)
})
}
private func checkForUpdates() {
isCheckingForUpdates = true
NotificationCenter.default.post(name: Notification.Name("checkForUpdates"), object: nil)
// Reset after a delay
Task {
try? await Task.sleep(for: .seconds(2))
isCheckingForUpdates = false
}
}
private func toggleServer() {
Task {
if tunnelServer.isRunning {
await tunnelServer.stop()
} else {
do {
try await tunnelServer.start()
} catch {
// Show error alert
await MainActor.run {
let alert = NSAlert()
alert.messageText = "Failed to Start Server"
alert.informativeText = error.localizedDescription
alert.alertStyle = .critical
alert.runModal()
}
}
}
}
}
}
#Preview {
SettingsView()
}

View file

@ -0,0 +1,39 @@
import SwiftUI
import AppKit
/// Window controller for the About window
final class AboutWindowController {
static let shared = AboutWindowController()
private var window: NSWindow?
private init() {}
func showWindow() {
// Check if About window is already open
if let existingWindow = window, existingWindow.isVisible {
existingWindow.makeKeyAndOrderFront(nil)
NSApp.activate(ignoringOtherApps: true)
return
}
// Create new About window
let aboutView = AboutView()
let hostingController = NSHostingController(rootView: aboutView)
let newWindow = NSWindow(contentViewController: hostingController)
newWindow.identifier = NSUserInterfaceItemIdentifier("AboutWindow")
newWindow.title = "About VibeTunnel"
newWindow.styleMask = [.titled, .closable, .miniaturizable]
newWindow.setContentSize(NSSize(width: 570, height: 600))
newWindow.center()
newWindow.isReleasedWhenClosed = false
// Store reference to window
self.window = newWindow
// Show window
newWindow.makeKeyAndOrderFront(nil)
NSApp.activate(ignoringOtherApps: true)
}
}

View file

@ -0,0 +1,183 @@
//
// VibeTunnelApp.swift
// VibeTunnel
//
// Created by Peter Steinberger on 15.06.25.
//
import SwiftUI
import AppKit
@main
struct VibeTunnelApp: App {
@NSApplicationDelegateAdaptor(AppDelegate.self)
var appDelegate
var body: some Scene {
#if os(macOS)
Settings {
SettingsView()
}
.commands {
CommandGroup(after: .appInfo) {
Button("About VibeTunnel") {
AboutWindowController.shared.showWindow()
}
}
}
#endif
}
}
// MARK: - App Delegate
@MainActor
final class AppDelegate: NSObject, NSApplicationDelegate {
private(set) var sparkleUpdaterManager: SparkleUpdaterManager?
private var statusItem: NSStatusItem?
/// Distributed notification name used to ask an existing instance to show the Settings window.
private static let showSettingsNotification = Notification.Name("com.amantus.vibetunnel.showSettings")
func applicationDidFinishLaunching(_ notification: Notification) {
let processInfo = ProcessInfo.processInfo
let isRunningInTests = processInfo.environment["XCTestConfigurationFilePath"] != nil
let isRunningInPreview = processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1"
let isRunningInDebug = processInfo.environment["DYLD_INSERT_LIBRARIES"]?.contains("libMainThreadChecker.dylib") ?? false
// Handle single instance check before doing anything else
if !isRunningInPreview, !isRunningInTests, !isRunningInDebug {
handleSingleInstanceCheck()
registerForDistributedNotifications()
}
// Initialize Sparkle updater manager
sparkleUpdaterManager = SparkleUpdaterManager()
// Configure activation policy based on settings (default to menu bar only)
let showInDock = UserDefaults.standard.bool(forKey: "showInDock")
NSApp.setActivationPolicy(showInDock ? .regular : .accessory)
// Setup status item (menu bar icon)
setupStatusItem()
// Show settings on first launch or when no window is open
if !showInDock {
// For menu bar apps, we need to ensure the settings window is accessible
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
if NSApp.windows.isEmpty || NSApp.windows.allSatisfy({ !$0.isVisible }) {
NSApp.sendAction(Selector(("showSettingsWindow:")), to: nil, from: nil)
}
}
}
// Listen for update check requests
NotificationCenter.default.addObserver(
self,
selector: #selector(handleCheckForUpdatesNotification),
name: Notification.Name("checkForUpdates"),
object: nil)
}
private func handleSingleInstanceCheck() {
let runningApps = NSRunningApplication
.runningApplications(withBundleIdentifier: Bundle.main.bundleIdentifier ?? "")
if runningApps.count > 1 {
// Send notification to existing instance to show settings
DistributedNotificationCenter.default().post(name: Self.showSettingsNotification, object: nil)
// Show alert that another instance is running
DispatchQueue.main.async {
let alert = NSAlert()
alert.messageText = "VibeTunnel is already running"
alert.informativeText = "Another instance of VibeTunnel is already running. This instance will now quit."
alert.alertStyle = .informational
alert.addButton(withTitle: "OK")
alert.runModal()
// Terminate this instance
NSApp.terminate(nil)
}
return
}
}
private func registerForDistributedNotifications() {
DistributedNotificationCenter.default().addObserver(
self,
selector: #selector(handleShowSettingsNotification),
name: Self.showSettingsNotification,
object: nil)
}
/// Shows the Settings window when another VibeTunnel instance asks us to.
@objc
private func handleShowSettingsNotification(_ notification: Notification) {
NSApp.activate(ignoringOtherApps: true)
NSApp.sendAction(Selector(("showSettingsWindow:")), to: nil, from: nil)
}
@objc private func handleCheckForUpdatesNotification() {
sparkleUpdaterManager?.checkForUpdates()
}
func applicationWillTerminate(_ notification: Notification) {
// Remove distributed notification observer
let processInfo = ProcessInfo.processInfo
let isRunningInTests = processInfo.environment["XCTestConfigurationFilePath"] != nil
let isRunningInPreview = processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1"
let isRunningInDebug = processInfo.environment["DYLD_INSERT_LIBRARIES"]?.contains("libMainThreadChecker.dylib") ?? false
if !isRunningInPreview, !isRunningInTests, !isRunningInDebug {
DistributedNotificationCenter.default().removeObserver(
self,
name: Self.showSettingsNotification,
object: nil)
}
// Remove update check notification observer
NotificationCenter.default.removeObserver(
self,
name: Notification.Name("checkForUpdates"),
object: nil)
}
// MARK: - Status Item
private func setupStatusItem() {
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
if let button = statusItem?.button {
button.image = NSImage(named: "menubar")
button.image?.isTemplate = true
button.action = #selector(statusItemClicked)
button.target = self
}
// Create menu
let menu = NSMenu()
menu.addItem(NSMenuItem(title: "Settings...", action: #selector(showSettings), keyEquivalent: ","))
menu.addItem(NSMenuItem.separator())
menu.addItem(NSMenuItem(title: "About VibeTunnel", action: #selector(showAbout), keyEquivalent: ""))
menu.addItem(NSMenuItem.separator())
menu.addItem(NSMenuItem(title: "Quit", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q"))
statusItem?.menu = menu
}
@objc private func statusItemClicked() {
// Left click shows menu
}
@objc private func showSettings() {
NSApp.sendAction(Selector(("showSettingsWindow:")), to: nil, from: nil)
NSApp.activate(ignoringOtherApps: true)
}
@objc private func showAbout() {
AboutWindowController.shared.showWindow()
NSApp.activate(ignoringOtherApps: true)
}
}

View file

@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>AppDomain</key>
<string>vibetunnel.sh</string>
<key>GitHubURL</key>
<string>https://github.com/amantus-ai/vibetunnel</string>
<key>AppcastURL</key>
<string>https://vibetunnel.sh/appcast.xml</string>
<key>AppName</key>
<string>VibeTunnel</string>
<key>CompanyName</key>
<string>Amantus AI</string>
<key>SupportEmail</key>
<string>support@amantus.ai</string>
<key>WebsiteURL</key>
<string>https://vibetunnel.sh</string>
</dict>
</plist>

View file

@ -0,0 +1,12 @@
# Sparkle Public EdDSA Key
# This file should contain your Sparkle public EdDSA key for app updates
#
# To generate a key pair:
# 1. Download Sparkle from https://sparkle-project.org/
# 2. Run: ./bin/generate_keys
# 3. Replace the placeholder below with your actual public key
# 4. Update the SUPublicEDKey in Info.plist with this key
#
# IMPORTANT: Keep your private key secure and never commit it to version control!
YOUR_PUBLIC_ED_KEY_HERE

View file

@ -0,0 +1,12 @@
// VibeTunnel Version Configuration
// This file contains the version and build number for the app
MARKETING_VERSION = 0.1
CURRENT_PROJECT_VERSION = 100
// Domain and GitHub configuration
APP_DOMAIN = vibetunnel.sh
GITHUB_URL = https://github.com/amantus-ai/vibetunnel
// Bundle identifier
PRODUCT_BUNDLE_IDENTIFIER = com.amantus.vibetunnel

View file

@ -0,0 +1,17 @@
//
// VibeTunnelTests.swift
// VibeTunnelTests
//
// Created by Peter Steinberger on 15.06.25.
//
import Testing
@testable import VibeTunnel
struct VibeTunnelTests {
@Test func example() async throws {
// Write your test here and use APIs like `#expect(...)` to check expected conditions.
}
}

View file

@ -0,0 +1,41 @@
//
// VibeTunnelUITests.swift
// VibeTunnelUITests
//
// Created by Peter Steinberger on 15.06.25.
//
import XCTest
final class VibeTunnelUITests: XCTestCase {
override func setUpWithError() throws {
// Put setup code here. This method is called before the invocation of each test method in the class.
// In UI tests it is usually best to stop immediately when a failure occurs.
continueAfterFailure = false
// In UI tests its important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this.
}
override func tearDownWithError() throws {
// Put teardown code here. This method is called after the invocation of each test method in the class.
}
@MainActor
func testExample() throws {
// UI tests must launch the application that they test.
let app = XCUIApplication()
app.launch()
// Use XCTAssert and related functions to verify your tests produce the correct results.
}
@MainActor
func testLaunchPerformance() throws {
// This measures how long it takes to launch your application.
measure(metrics: [XCTApplicationLaunchMetric()]) {
XCUIApplication().launch()
}
}
}

View file

@ -0,0 +1,33 @@
//
// VibeTunnelUITestsLaunchTests.swift
// VibeTunnelUITests
//
// Created by Peter Steinberger on 15.06.25.
//
import XCTest
final class VibeTunnelUITestsLaunchTests: XCTestCase {
override class var runsForEachTargetApplicationUIConfiguration: Bool {
true
}
override func setUpWithError() throws {
continueAfterFailure = false
}
@MainActor
func testLaunch() throws {
let app = XCUIApplication()
app.launch()
// Insert steps here to perform after app launch but before taking a screenshot,
// such as logging into a test account or navigating somewhere in the app
let attachment = XCTAttachment(screenshot: app.screenshot())
attachment.name = "Launch Screen"
attachment.lifetime = .keepAlways
add(attachment)
}
}

View file

@ -0,0 +1,188 @@
# Hummingbird Integration Guide for VibeTunnel
This guide explains the Hummingbird web framework integration in VibeTunnel for creating the tunnel server functionality.
## Current Status
**IMPLEMENTED** - The VibeTunnel server is now fully implemented with:
- HTTP REST API endpoints for terminal session management
- WebSocket support for real-time terminal communication
- Authentication via API keys
- Session management with automatic cleanup
- Client SDK for easy integration
- Comprehensive error handling
## Architecture Overview
The VibeTunnel server is built with the following components:
### Core Components
1. **TunnelServer** (`/VibeTunnel/Core/Services/TunnelServer.swift`)
- Main server implementation using Hummingbird
- Manages HTTP endpoints and WebSocket connections
- Handles server lifecycle and configuration
2. **TerminalManager** (`/VibeTunnel/Core/Services/TerminalManager.swift`)
- Actor-based terminal session management
- Handles process creation and command execution
- Manages pipes for stdin/stdout/stderr communication
- Automatic cleanup of inactive sessions
3. **WebSocketHandler** (`/VibeTunnel/Core/Services/WebSocketHandler.swift`)
- Real-time bidirectional communication
- JSON-based message protocol
- Session-based terminal streaming
4. **AuthenticationMiddleware** (`/VibeTunnel/Core/Services/AuthenticationMiddleware.swift`)
- API key-based authentication
- Secure key generation and storage
- Protects all endpoints except health check
5. **TunnelClient** (`/VibeTunnel/Core/Services/TunnelClient.swift`)
- Swift SDK for server interaction
- Async/await based API
- WebSocket client for real-time communication
### Data Models
- **TunnelSession** - Represents a terminal session
- **CreateSessionRequest/Response** - Session creation
- **CommandRequest/Response** - Command execution
- **WSMessage** - WebSocket message format
## API Endpoints
### REST API
- `GET /health` - Health check (no auth required)
- `GET /info` - Server information
- `GET /sessions` - List all active sessions
- `POST /sessions` - Create new terminal session
- `GET /sessions/:id` - Get session details
- `DELETE /sessions/:id` - Close a session
- `POST /execute` - Execute command in session
### WebSocket
- `WS /ws/terminal` - Real-time terminal communication
## Example Implementation
```swift
import Foundation
import Hummingbird
import HummingbirdCore
import Logging
import NIOCore
// Basic server implementation
struct TunnelServerApp {
let logger = Logger(label: "VibeTunnel.Server")
func buildApplication() -> some ApplicationProtocol {
let router = Router()
// Health check endpoint
router.get("/health") { request, context -> [String: Any] in
return [
"status": "ok",
"timestamp": Date().timeIntervalSince1970
]
}
// Command endpoint
router.post("/tunnel/command") { request, context -> Response in
struct CommandRequest: Decodable {
let command: String
let args: [String]?
}
let commandRequest = try await request.decode(
as: CommandRequest.self,
context: context
)
// Process command here
logger.info("Received command: \(commandRequest.command)")
return Response(
status: .ok,
headers: HTTPFields([
.contentType: "application/json"
]),
body: .data(Data("{\"success\":true}".utf8))
)
}
let app = Application(
router: router,
configuration: .init(
address: .hostname("127.0.0.1", port: 8080)
)
)
return app
}
}
```
## WebSocket Support
For real-time communication with Claude Code, you'll want to add WebSocket support:
```swift
// Add HummingbirdWebSocket dependency first
import HummingbirdWebSocket
// Then add WebSocket routes
router.ws("/tunnel/stream") { request, ws, context in
ws.onText { ws, text in
// Handle incoming text messages
logger.info("Received: \(text)")
// Echo back or process command
try await ws.send(text: "Acknowledged: \(text)")
}
ws.onBinary { ws, buffer in
// Handle binary data if needed
}
ws.onClose { closeCode in
logger.info("WebSocket closed: \(closeCode)")
}
}
```
## Integration Steps
1. **Update the Package Dependencies**: Make sure to include any additional Hummingbird modules you need (like HummingbirdWebSocket).
2. **Replace the Placeholder**: Update `TunnelServer.swift` with the actual Hummingbird implementation.
3. **Handle Concurrency**: Since the server runs asynchronously, ensure proper handling of the server lifecycle with the SwiftUI app lifecycle.
4. **Add Security**: Implement authentication and secure communication for production use.
## Testing the Server
Once implemented, you can test the server with curl:
```bash
# Health check
curl http://localhost:8080/health
# Send a command
curl -X POST http://localhost:8080/tunnel/command \
-H "Content-Type: application/json" \
-d '{"command":"ls","args":["-la"]}'
```
## Next Steps
1. Implement actual command execution logic
2. Add authentication/authorization
3. Implement WebSocket support for real-time communication
4. Add SSL/TLS support for secure connections
5. Create client SDK for easy integration

165
docs/modern-swift.md Normal file
View file

@ -0,0 +1,165 @@
# Modern Swift Development
Write idiomatic SwiftUI code following Apple's latest architectural recommendations and best practices.
## Core Philosophy
- SwiftUI is the default UI paradigm for Apple platforms - embrace its declarative nature
- Avoid legacy UIKit patterns and unnecessary abstractions
- Focus on simplicity, clarity, and native data flow
- Let SwiftUI handle the complexity - don't fight the framework
## Architecture Guidelines
### 1. Embrace Native State Management
Use SwiftUI's built-in property wrappers appropriately:
- `@State` - Local, ephemeral view state
- `@Binding` - Two-way data flow between views
- `@Observable` - Shared state (iOS 17+)
- `@ObservableObject` - Legacy shared state (pre-iOS 17)
- `@Environment` - Dependency injection for app-wide concerns
### 2. State Ownership Principles
- Views own their local state unless sharing is required
- State flows down, actions flow up
- Keep state as close to where it's used as possible
- Extract shared state only when multiple views need it
### 3. Modern Async Patterns
- Use `async/await` as the default for asynchronous operations
- Leverage `.task` modifier for lifecycle-aware async work
- Avoid Combine unless absolutely necessary
- Handle errors gracefully with try/catch
### 4. View Composition
- Build UI with small, focused views
- Extract reusable components naturally
- Use view modifiers to encapsulate common styling
- Prefer composition over inheritance
### 5. Code Organization
- Organize by feature, not by type (avoid Views/, Models/, ViewModels/ folders)
- Keep related code together in the same file when appropriate
- Use extensions to organize large files
- Follow Swift naming conventions consistently
## Implementation Patterns
### Simple State Example
```swift
struct CounterView: View {
@State private var count = 0
var body: some View {
VStack {
Text("Count: \(count)")
Button("Increment") {
count += 1
}
}
}
}
```
### Shared State with @Observable
```swift
@Observable
class UserSession {
var isAuthenticated = false
var currentUser: User?
func signIn(user: User) {
currentUser = user
isAuthenticated = true
}
}
struct MyApp: App {
@State private var session = UserSession()
var body: some Scene {
WindowGroup {
ContentView()
.environment(session)
}
}
}
```
### Async Data Loading
```swift
struct ProfileView: View {
@State private var profile: Profile?
@State private var isLoading = false
@State private var error: Error?
var body: some View {
Group {
if isLoading {
ProgressView()
} else if let profile {
ProfileContent(profile: profile)
} else if let error {
ErrorView(error: error)
}
}
.task {
await loadProfile()
}
}
private func loadProfile() async {
isLoading = true
defer { isLoading = false }
do {
profile = try await ProfileService.fetch()
} catch {
self.error = error
}
}
}
```
## Best Practices
### DO:
- Write self-contained views when possible
- Use property wrappers as intended by Apple
- Test logic in isolation, preview UI visually
- Handle loading and error states explicitly
- Keep views focused on presentation
- Use Swift's type system for safety
### DON'T:
- Create ViewModels for every view
- Move state out of views unnecessarily
- Add abstraction layers without clear benefit
- Use Combine for simple async operations
- Fight SwiftUI's update mechanism
- Overcomplicate simple features
## Testing Strategy
- Unit test business logic and data transformations
- Use SwiftUI Previews for visual testing
- Test @Observable classes independently
- Keep tests simple and focused
- Don't sacrifice code clarity for testability
## Modern Swift Features
- Use Swift Concurrency (async/await, actors)
- Leverage Swift 6 data race safety when available
- Utilize property wrappers effectively
- Embrace value types where appropriate
- Use protocols for abstraction, not just for testing
## Summary
Write SwiftUI code that looks and feels like SwiftUI. The framework has matured significantly - trust its patterns and tools. Focus on solving user problems rather than implementing architectural patterns from other platforms.

68899
docs/swiftui.md Normal file

File diff suppressed because it is too large Load diff

51689
docs/uikit.md Normal file

File diff suppressed because it is too large Load diff

210
scripts/README.md Normal file
View file

@ -0,0 +1,210 @@
# VibeTunnel Scripts Directory
This directory contains all automation scripts for VibeTunnel development, building, and release management. Each script is thoroughly documented with headers explaining usage, dependencies, and examples.
## 📋 Script Categories
### 🏗️ **Core Development Scripts**
| Script | Purpose | Usage |
|--------|---------|-------|
| [`generate-xcproj.sh`](./generate-xcproj.sh) | Generate Xcode project with Tuist | `./scripts/generate-xcproj.sh` |
| [`build.sh`](./build.sh) | Build VibeTunnel app with optional signing | `./scripts/build.sh [--configuration Debug\|Release] [--sign]` |
### 🚀 **Release Management Scripts**
| Script | Purpose | Usage |
|--------|---------|-------|
| [`preflight-check.sh`](./preflight-check.sh) | Validate release readiness | `./scripts/preflight-check.sh` |
| [`release.sh`](./release.sh) | **Main release automation script** | `./scripts/release.sh <stable\|beta\|alpha\|rc> [number]` |
| [`version.sh`](./version.sh) | Manage version numbers | `./scripts/version.sh --patch\|--minor\|--major` |
### 🔐 **Code Signing & Distribution**
| Script | Purpose | Usage |
|--------|---------|-------|
| [`sign-and-notarize.sh`](./sign-and-notarize.sh) | Sign and notarize app bundles | `./scripts/sign-and-notarize.sh --sign-and-notarize` |
| [`codesign-app.sh`](./codesign-app.sh) | Code sign app bundle only | `./scripts/codesign-app.sh <app-path>` |
| [`notarize-app.sh`](./notarize-app.sh) | Notarize signed app bundle | `./scripts/notarize-app.sh <app-path>` |
| [`create-dmg.sh`](./create-dmg.sh) | Create and sign DMG files | `./scripts/create-dmg.sh <app-path> [dmg-path]` |
### 📡 **Update System Scripts**
| Script | Purpose | Usage |
|--------|---------|-------|
| [`generate-appcast.sh`](./generate-appcast.sh) | Generate Sparkle appcast XML | `./scripts/generate-appcast.sh` |
### ✅ **Verification & Testing Scripts**
| Script | Purpose | Usage |
|--------|---------|-------|
| [`verify-app.sh`](./verify-app.sh) | Verify app signing and notarization | `./scripts/verify-app.sh <app-or-dmg-path>` |
| [`verify-appcast.sh`](./verify-appcast.sh) | Validate appcast XML files | `./scripts/verify-appcast.sh` |
### 🛠️ **Utility Scripts**
| Script | Purpose | Usage |
|--------|---------|-------|
| [`changelog-to-html.sh`](./changelog-to-html.sh) | Convert changelog to HTML for appcast | `./scripts/changelog-to-html.sh <version>` |
| [`extract-build-number.sh`](./extract-build-number.sh) | Extract build number from DMG | `./scripts/extract-build-number.sh <dmg-path>` |
## 🔄 **Common Workflows**
### **Development Workflow**
```bash
# 1. Generate Xcode project (after Project.swift changes)
./scripts/generate-xcproj.sh
# 2. Build and test
./scripts/build.sh --configuration Debug
```
### **Release Workflow**
```bash
# 1. Check release readiness
./scripts/preflight-check.sh
# 2. Create release (choose appropriate type)
./scripts/release.sh stable # Production release
./scripts/release.sh beta 1 # Beta release
./scripts/release.sh alpha 2 # Alpha release
./scripts/release.sh rc 1 # Release candidate
```
### **Manual Build & Distribution**
```bash
# 1. Build app
./scripts/build.sh --configuration Release
# 2. Sign and notarize
./scripts/sign-and-notarize.sh --sign-and-notarize
# 3. Create DMG
./scripts/create-dmg.sh build/Build/Products/Release/VibeTunnel.app
# 4. Verify final package
./scripts/verify-app.sh build/VibeTunnel-*.dmg
```
## 🔧 **IS_PRERELEASE_BUILD System**
The `IS_PRERELEASE_BUILD` system ensures beta downloads automatically default to the pre-release update channel:
- **Project.swift**: Contains `"IS_PRERELEASE_BUILD": "$(IS_PRERELEASE_BUILD)"` configuration
- **release.sh**: Sets `IS_PRERELEASE_BUILD=YES` for beta builds, `NO` for stable builds
- **UpdateChannel.swift**: Checks the flag to determine default update channel
## 📦 **Dependencies**
### **Required Tools**
- **Xcode** - iOS/macOS development environment
- **Tuist** - Project generation (`brew install tuist`)
- **GitHub CLI** - Release management (`brew install gh`)
### **Code Quality Tools**
- **xcbeautify** - Pretty build output (`brew install xcbeautify`)
### **Sparkle Tools**
- **sign_update** - EdDSA signing for appcast updates
- **generate_appcast** - Appcast XML generation
- **generate_keys** - EdDSA key generation
Install Sparkle tools:
```bash
curl -L "https://github.com/sparkle-project/Sparkle/releases/download/2.7.0/Sparkle-2.7.0.tar.xz" -o Sparkle-2.7.0.tar.xz
tar -xf Sparkle-2.7.0.tar.xz
mkdir -p ~/.local/bin
cp bin/sign_update bin/generate_appcast bin/generate_keys ~/.local/bin/
export PATH="$HOME/.local/bin:$PATH"
```
## 🔐 **Environment Variables**
### **Required for Release**
```bash
# App Store Connect API (for notarization)
export APP_STORE_CONNECT_API_KEY_P8="-----BEGIN PRIVATE KEY-----..."
export APP_STORE_CONNECT_KEY_ID="ABCDEF1234"
export APP_STORE_CONNECT_ISSUER_ID="12345678-1234-1234-1234-123456789012"
```
### **Optional for Development**
```bash
# Pre-release build flag (automatically set by release.sh)
export IS_PRERELEASE_BUILD=YES # or NO
# CI certificate for signing
export MACOS_SIGNING_CERTIFICATE_P12_BASE64="..."
```
## 🧹 **Maintenance Notes**
### **Script Documentation Standards**
All scripts follow this documentation format:
```bash
#!/bin/bash
# =============================================================================
# VibeTunnel [Script Name]
# =============================================================================
#
# [Description of what the script does]
#
# USAGE:
# ./scripts/script-name.sh [arguments]
#
# [Additional sections as needed: FEATURES, DEPENDENCIES, EXAMPLES, etc.]
#
# =============================================================================
```
### **Script Categories by Complexity**
- **Simple Scripts**: Basic single-purpose utilities
- **Medium Scripts**: build.sh, generate-xcproj.sh - Multi-step processes
- **Complex Scripts**: release.sh, sign-and-notarize.sh - Full automation workflows
### **Testing Scripts**
Most scripts can be tested safely:
- Development scripts (build.sh) are safe to run anytime
- Verification scripts are read-only and safe
- Release scripts should only be run when creating actual releases
### **Script Interdependencies**
```
release.sh (main release script)
├── preflight-check.sh (validation)
├── generate-xcproj.sh (project generation)
├── build.sh (compilation)
├── sign-and-notarize.sh (code signing)
├── create-dmg.sh (packaging)
├── generate-appcast.sh (update feed)
└── verify-app.sh (verification)
```
## 🔍 **Troubleshooting**
### **Common Issues**
1. **"command not found"** - Install missing dependencies listed above
2. **"No signing identity found"** - Set up Apple Developer certificates
3. **"Notarization failed"** - Check App Store Connect API credentials
4. **"Tuist generation failed"** - Ensure Project.swift syntax is valid
### **Debug Tips**
- Run `./scripts/preflight-check.sh` to validate setup
- Check individual script headers for specific requirements
- Use `--verbose` flags where available for detailed output
- Verify environment variables are properly set
## 📝 **Adding New Scripts**
When adding new scripts:
1. Follow the documentation header format above
2. Add appropriate error handling (`set -euo pipefail`)
3. Include usage examples and dependency information
4. Update this README.md with the new script
5. Test thoroughly before committing
---
**Last Updated**: December 2024
**Maintainer**: VibeTunnel Development Team

154
scripts/build.sh Executable file
View file

@ -0,0 +1,154 @@
#!/bin/bash
# =============================================================================
# VibeTunnel Build Script
# =============================================================================
#
# This script builds the VibeTunnel application using xcodebuild with optional
# code signing support. It includes comprehensive error checking and reports
# build details including the IS_PRERELEASE_BUILD flag status.
#
# USAGE:
# ./scripts/build.sh [--configuration Debug|Release] [--sign]
#
# ARGUMENTS:
# --configuration <Debug|Release> Build configuration (default: Release)
# --sign Sign the app after building (requires cert)
#
# ENVIRONMENT VARIABLES:
# IS_PRERELEASE_BUILD=YES|NO Sets pre-release flag in Info.plist
# MACOS_SIGNING_CERTIFICATE_P12_BASE64 CI certificate for signing
#
# OUTPUTS:
# - Built app at: build/Build/Products/<Configuration>/VibeTunnel.app
# - Version and build number information
# - IS_PRERELEASE_BUILD flag status verification
#
# DEPENDENCIES:
# - Xcode and command line tools
# - xcbeautify (optional, for prettier output)
# - Generated Xcode project (run generate-xcproj.sh first)
#
# EXAMPLES:
# ./scripts/build.sh # Release build
# ./scripts/build.sh --configuration Debug # Debug build
# ./scripts/build.sh --sign # Release build with signing
# IS_PRERELEASE_BUILD=YES ./scripts/build.sh # Beta build
#
# =============================================================================
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
BUILD_DIR="$PROJECT_DIR/build"
# Default values
CONFIGURATION="Release"
SIGN_APP=false
# Parse arguments
while [[ $# -gt 0 ]]; do
case $1 in
--configuration)
CONFIGURATION="$2"
shift 2
;;
--sign)
SIGN_APP=true
shift
;;
*)
echo "Unknown option: $1"
echo "Usage: $0 [--configuration Debug|Release] [--sign]"
exit 1
;;
esac
done
echo "Building VibeTunnel..."
echo "Configuration: $CONFIGURATION"
echo "Code signing: $SIGN_APP"
# Clean build directory only if it doesn't exist
mkdir -p "$BUILD_DIR"
# Build the app
cd "$PROJECT_DIR"
# Use CI-specific configuration if in CI environment
XCCONFIG_ARG=""
if [[ "${CI:-false}" == "true" ]] && [[ -f "$PROJECT_DIR/.xcode-ci-config.xcconfig" ]]; then
echo "Using CI-specific build configuration"
XCCONFIG_ARG="-xcconfig $PROJECT_DIR/.xcode-ci-config.xcconfig"
fi
# Check if xcbeautify is available
if command -v xcbeautify &> /dev/null; then
echo "🔨 Building with xcbeautify..."
xcodebuild \
-workspace VibeTunnel.xcworkspace \
-scheme VibeTunnel \
-configuration "$CONFIGURATION" \
-derivedDataPath "$BUILD_DIR" \
-destination "platform=macOS" \
$XCCONFIG_ARG \
build | xcbeautify
else
echo "🔨 Building (install xcbeautify for cleaner output)..."
xcodebuild \
-workspace VibeTunnel.xcworkspace \
-scheme VibeTunnel \
-configuration "$CONFIGURATION" \
-derivedDataPath "$BUILD_DIR" \
-destination "platform=macOS" \
$XCCONFIG_ARG \
build
fi
APP_PATH="$BUILD_DIR/Build/Products/$CONFIGURATION/VibeTunnel.app"
if [[ ! -d "$APP_PATH" ]]; then
echo "Error: Build failed - app not found at $APP_PATH"
exit 1
fi
# Sparkle sandbox fix is no longer needed - we use default XPC services
# The fix-sparkle-sandbox.sh script now just verifies configuration
if [[ "$CONFIGURATION" == "Release" ]]; then
if [ -x "$SCRIPT_DIR/fix-sparkle-sandbox.sh" ]; then
echo "Verifying Sparkle configuration..."
"$SCRIPT_DIR/fix-sparkle-sandbox.sh" "$APP_PATH"
fi
fi
# Sign the app if requested
if [[ "$SIGN_APP" == true ]]; then
if [[ -n "${MACOS_SIGNING_CERTIFICATE_P12_BASE64:-}" ]]; then
echo "Signing app with CI certificate..."
"$SCRIPT_DIR/codesign-app.sh" "$APP_PATH"
else
echo "Warning: Signing requested but no certificate configured"
fi
fi
echo "Build complete: $APP_PATH"
# Print version info
VERSION=$(/usr/libexec/PlistBuddy -c "Print CFBundleShortVersionString" "$APP_PATH/Contents/Info.plist")
BUILD=$(/usr/libexec/PlistBuddy -c "Print CFBundleVersion" "$APP_PATH/Contents/Info.plist")
echo "Version: $VERSION ($BUILD)"
# Verify IS_PRERELEASE_BUILD flag
PRERELEASE_FLAG=$(/usr/libexec/PlistBuddy -c "Print IS_PRERELEASE_BUILD" "$APP_PATH/Contents/Info.plist" 2>/dev/null || echo "not found")
if [[ "$PRERELEASE_FLAG" != "not found" ]]; then
if [[ "$PRERELEASE_FLAG" == "YES" ]]; then
echo "✓ IS_PRERELEASE_BUILD: YES (pre-release build)"
elif [[ "$PRERELEASE_FLAG" == "NO" ]]; then
echo "✓ IS_PRERELEASE_BUILD: NO (stable build)"
else
echo "⚠ IS_PRERELEASE_BUILD: '$PRERELEASE_FLAG' (unexpected value)"
fi
else
echo "⚠ IS_PRERELEASE_BUILD: not set (will use version string fallback)"
fi

131
scripts/changelog-to-html.sh Executable file
View file

@ -0,0 +1,131 @@
#!/bin/bash
# =============================================================================
# VibeTunnel Changelog to HTML Converter
# =============================================================================
#
# Converts specific version sections from CHANGELOG.md to HTML format for
# inclusion in Sparkle appcast descriptions. Supports markdown formatting
# including headers, lists, bold text, code, and links.
#
# USAGE:
# ./scripts/changelog-to-html.sh <version> [changelog_file]
#
# ARGUMENTS:
# version Version to extract (e.g., "0.1")
# changelog_file Path to changelog file (default: CHANGELOG.md)
#
# OUTPUT:
# HTML formatted changelog section suitable for Sparkle appcast
#
# EXAMPLES:
# ./scripts/changelog-to-html.sh 0.1
# ./scripts/changelog-to-html.sh 0.1 docs/CHANGELOG.md
#
# =============================================================================
set -euo pipefail
VERSION="${1:-}"
CHANGELOG_FILE="${2:-CHANGELOG.md}"
if [ -z "$VERSION" ]; then
echo "Usage: $0 <version> [changelog_file]"
echo "Example: $0 0.1 CHANGELOG.md"
exit 1
fi
if [ ! -f "$CHANGELOG_FILE" ]; then
echo "Error: Changelog file '$CHANGELOG_FILE' not found"
exit 1
fi
# Function to convert markdown to basic HTML
markdown_to_html() {
local text="$1"
# Convert headers
text=$(echo "$text" | sed 's/^### \(.*\)$/<h3>\1<\/h3>/')
text=$(echo "$text" | sed 's/^## \(.*\)$/<h2>\1<\/h2>/')
# Convert bullet points with emoji support
text=$(echo "$text" | sed 's/^- \*\*\([^*]*\)\*\*\(.*\)$/<li><strong>\1<\/strong>\2<\/li>/')
text=$(echo "$text" | sed 's/^- \([^*].*\)$/<li>\1<\/li>/')
# Convert bold text
text=$(echo "$text" | sed 's/\*\*\([^*]*\)\*\*/\<strong\>\1\<\/strong\>/g')
# Convert inline code
text=$(echo "$text" | sed 's/`\([^`]*\)`/<code>\1<\/code>/g')
# Convert links [text](url) to <a href="url">text</a>
text=$(echo "$text" | sed 's/\[\([^]]*\)\](\([^)]*\))/<a href="\2">\1<\/a>/g')
echo "$text"
}
# Extract version section from changelog
extract_version_section() {
local version="$1"
local file="$2"
# Look for version header (supports [0.1] or ## 0.1 formats)
# Extract from version header to next version header or end of file
awk -v version="$version" '
BEGIN { found=0; print_section=0 }
/^## \[/ && $0 ~ "\\[" version "\\]" { found=1; print_section=1; next }
found && print_section && /^## / { print_section=0 }
found && print_section { print }
' "$file"
}
# Main processing
# Note: Debug output redirected to stderr to avoid polluting HTML output
echo "Extracting changelog for version $VERSION..." >&2
# Extract the version section
version_content=$(extract_version_section "$VERSION" "$CHANGELOG_FILE")
if [ -z "$version_content" ]; then
echo "Warning: No changelog section found for version $VERSION" >&2
echo "Using default content..." >&2
cat << EOF
<h2>VibeTunnel $VERSION</h2>
<p>Latest version of VibeTunnel with new features and improvements.</p>
<p><a href="https://github.com/amantus-ai/vibetunnel/blob/main/CHANGELOG.md">View full changelog</a></p>
EOF
exit 0
fi
# Convert to HTML
# Note: Title is handled by the calling script (e.g., generate-appcast.sh)
# Process line by line to handle lists properly
in_list=false
while IFS= read -r line; do
if [[ "$line" =~ ^- ]]; then
if [ "$in_list" = false ]; then
echo "<ul>"
in_list=true
fi
markdown_to_html "$line"
else
if [ "$in_list" = true ]; then
echo "</ul>"
in_list=false
fi
# Skip empty lines and date headers
if [ -n "$line" ] && [[ ! "$line" =~ ^\[.*\].*[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]]; then
markdown_to_html "$line"
fi
fi
done <<< "$version_content"
# Close list if still open
if [ "$in_list" = true ]; then
echo "</ul>"
fi
# Add link to full changelog
echo "<p><a href=\"https://github.com/amantus-ai/vibetunnel/blob/main/CHANGELOG.md#${VERSION//./}-$(date +%Y%m%d)\">View full changelog</a></p>"

125
scripts/codesign-app.sh Executable file
View file

@ -0,0 +1,125 @@
#!/bin/bash
# codesign-app.sh - Code signing script for VibeTunnel
set -euo pipefail
log() {
echo "[$(date "+%Y-%m-%d %H:%M:%S")] $1"
}
# Default parameters
APP_BUNDLE="${1:-build/Build/Products/Release/VibeTunnel.app}"
SIGN_IDENTITY="${2:-Developer ID Application}"
# Validate input
if [ ! -d "$APP_BUNDLE" ]; then
log "Error: App bundle not found at $APP_BUNDLE"
log "Usage: $0 <app_path> [signing_identity]"
exit 1
fi
log "Code signing $APP_BUNDLE with identity: $SIGN_IDENTITY"
# Create entitlements with hardened runtime
ENTITLEMENTS_FILE="VibeTunnel/VibeTunnel.entitlements"
TMP_ENTITLEMENTS="/tmp/VibeTunnel_entitlements.plist"
if [ -f "$ENTITLEMENTS_FILE" ]; then
log "Using entitlements from $ENTITLEMENTS_FILE"
# Get the bundle identifier from the Info.plist
BUNDLE_ID=$(defaults read "$APP_BUNDLE/Contents/Info.plist" CFBundleIdentifier 2>/dev/null || echo "com.amantus.vibetunnel")
log "Bundle identifier: $BUNDLE_ID"
# Copy entitlements and replace variables
sed -e "s/\$(PRODUCT_BUNDLE_IDENTIFIER)/$BUNDLE_ID/g" "$ENTITLEMENTS_FILE" > "$TMP_ENTITLEMENTS"
# Ensure hardened runtime is enabled
if ! grep -q "com.apple.security.hardened-runtime" "$TMP_ENTITLEMENTS"; then
awk '/<\/dict>/ { print " <key>com.apple.security.hardened-runtime</key>\n <true/>"; } { print; }' "$TMP_ENTITLEMENTS" > "${TMP_ENTITLEMENTS}.new"
mv "${TMP_ENTITLEMENTS}.new" "$TMP_ENTITLEMENTS"
fi
else
log "Creating entitlements file with hardened runtime..."
# Get the bundle identifier
BUNDLE_ID=$(defaults read "$APP_BUNDLE/Contents/Info.plist" CFBundleIdentifier 2>/dev/null || echo "com.amantus.vibetunnel")
log "Bundle identifier: $BUNDLE_ID"
cat > "$TMP_ENTITLEMENTS" << EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.hardened-runtime</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>
<key>com.apple.security.files.user-selected.read-only</key>
<true/>
<key>com.apple.security.files.downloads.read-write</key>
<true/>
<key>com.apple.security.automation.apple-events</key>
<true/>
<!-- Sparkle XPC Service temporary exceptions -->
<key>com.apple.security.temporary-exception.mach-lookup.global-name</key>
<array>
<string>${BUNDLE_ID}-spks</string>
<string>${BUNDLE_ID}-spkd</string>
</array>
</dict>
</plist>
EOF
fi
# Clean up any existing signatures and quarantine attributes
log "Preparing app bundle for signing..."
xattr -cr "$APP_BUNDLE" 2>/dev/null || true
# Check if we're in CI and have a specific keychain
KEYCHAIN_OPTS=""
if [ -n "${KEYCHAIN_NAME:-}" ]; then
log "Using keychain: $KEYCHAIN_NAME"
KEYCHAIN_OPTS="--keychain $KEYCHAIN_NAME"
fi
# Sign frameworks first (if any)
if [ -d "$APP_BUNDLE/Contents/Frameworks" ]; then
log "Signing embedded frameworks..."
find "$APP_BUNDLE/Contents/Frameworks" \( -type d -name "*.framework" -o -type f -name "*.dylib" \) 2>/dev/null | while read -r framework; do
log "Signing framework: $framework"
codesign --force --options runtime --sign "$SIGN_IDENTITY" $KEYCHAIN_OPTS "$framework" || log "Warning: Failed to sign $framework"
done
fi
# Sign the main executable
log "Signing main executable..."
codesign --force --options runtime --entitlements "$TMP_ENTITLEMENTS" --sign "$SIGN_IDENTITY" $KEYCHAIN_OPTS "$APP_BUNDLE/Contents/MacOS/VibeTunnel" || true
# Sign the app bundle WITHOUT deep signing (per Sparkle documentation)
# "Due to different code signing requirements, please do not add --deep to
# OTHER_CODE_SIGN_FLAGS or from custom build scripts when signing your application.
# This is a common source of Sandboxing errors."
log "Signing complete app bundle (without --deep per Sparkle requirements)..."
codesign --force --options runtime --entitlements "$TMP_ENTITLEMENTS" --sign "$SIGN_IDENTITY" $KEYCHAIN_OPTS "$APP_BUNDLE"
# Verify the signature
log "Verifying code signature..."
if codesign --verify --verbose=2 "$APP_BUNDLE" 2>&1; then
log "✅ Code signature verification passed"
else
log "⚠️ Code signature verification had warnings (may be expected in CI)"
fi
# Test with spctl (may fail without proper certificates)
if spctl -a -t exec -vv "$APP_BUNDLE" 2>&1; then
log "✅ spctl verification passed"
else
log "⚠️ spctl verification failed (expected without proper Developer ID certificate)"
fi
# Clean up
rm -f "$TMP_ENTITLEMENTS"
log "✅ Code signing completed successfully"

203
scripts/create-dmg.sh Executable file
View file

@ -0,0 +1,203 @@
#!/bin/bash
set -euo pipefail
# Script to create a DMG for VibeTunnel
# Usage: ./scripts/create-dmg.sh <app_path> [output_path]
if [[ $# -lt 1 ]] || [[ $# -gt 2 ]]; then
echo "Usage: $0 <app_path> [output_path]"
exit 1
fi
APP_PATH="$1"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
BUILD_DIR="$PROJECT_DIR/build"
if [[ ! -d "$APP_PATH" ]]; then
echo "Error: App not found at $APP_PATH"
exit 1
fi
# Get version info
VERSION=$(/usr/libexec/PlistBuddy -c "Print CFBundleShortVersionString" "$APP_PATH/Contents/Info.plist")
DMG_NAME="VibeTunnel-${VERSION}.dmg"
# Use provided output path or default
if [[ $# -eq 2 ]]; then
DMG_PATH="$2"
else
DMG_PATH="$BUILD_DIR/$DMG_NAME"
fi
echo "Creating DMG: $DMG_NAME"
# Create temporary directory for DMG contents
DMG_TEMP="$BUILD_DIR/dmg-temp"
rm -rf "$DMG_TEMP"
mkdir -p "$DMG_TEMP"
# Copy app to temporary directory
cp -R "$APP_PATH" "$DMG_TEMP/"
# Create symbolic link to Applications folder
ln -s /Applications "$DMG_TEMP/Applications"
# Create DMG
hdiutil create \
-volname "VibeTunnel" \
-srcfolder "$DMG_TEMP" \
-ov \
-format UDZO \
"$DMG_PATH"
# Clean up
rm -rf "$DMG_TEMP"
# === EXTENSIVE ENVIRONMENT DEBUGGING ===
echo "=== Environment Debug Information ==="
echo "Current working directory: $(pwd)"
echo "User: $(whoami)"
echo "Date: $(date)"
echo "Environment variables related to signing:"
echo " KEYCHAIN_NAME=${KEYCHAIN_NAME:-<not set>}"
echo " SIGN_IDENTITY=${SIGN_IDENTITY:-<not set>}"
echo " RUNNER_TEMP=${RUNNER_TEMP:-<not set>}"
echo " GITHUB_ACTIONS=${GITHUB_ACTIONS:-<not set>}"
echo " CI=${CI:-<not set>}"
# Check if secrets are available (without exposing their values)
echo "GitHub Secrets Status:"
echo " APP_STORE_CONNECT_API_KEY_P8: ${APP_STORE_CONNECT_API_KEY_P8:+SET}"
echo " APP_STORE_CONNECT_ISSUER_ID: ${APP_STORE_CONNECT_ISSUER_ID:+SET}"
echo " APP_STORE_CONNECT_KEY_ID: ${APP_STORE_CONNECT_KEY_ID:+SET}"
echo " MACOS_SIGNING_CERTIFICATE_P12_BASE64: ${MACOS_SIGNING_CERTIFICATE_P12_BASE64:+SET}"
echo " MACOS_SIGNING_CERTIFICATE_PASSWORD: ${MACOS_SIGNING_CERTIFICATE_PASSWORD:+SET}"
# List all keychains
echo "=== Keychain Information ==="
echo "Available keychains:"
security list-keychains -d user || echo "Failed to list user keychains"
security list-keychains -d system || echo "Failed to list system keychains"
echo ""
echo "Default keychain:"
security default-keychain -d user || echo "Failed to get default user keychain"
# Check if specific keychain exists
if [ -n "${KEYCHAIN_NAME:-}" ]; then
echo ""
echo "Checking for specified keychain: $KEYCHAIN_NAME"
if security list-keychains -d user | grep -q "$KEYCHAIN_NAME"; then
echo "✅ Keychain $KEYCHAIN_NAME found in user domain"
else
echo "❌ Keychain $KEYCHAIN_NAME NOT found in user domain"
fi
# Try to unlock the keychain if it exists
if [ -f "$KEYCHAIN_NAME" ]; then
echo "Keychain file exists at: $KEYCHAIN_NAME"
echo "Checking keychain lock status..."
security show-keychain-info "$KEYCHAIN_NAME" 2>&1 || echo "Cannot get keychain info"
else
echo "Keychain file does not exist at: $KEYCHAIN_NAME"
fi
fi
# === SIGNING IDENTITY ANALYSIS ===
echo ""
echo "=== Signing Identity Analysis ==="
# Sign the DMG if signing credentials are available
if command -v codesign &> /dev/null; then
echo "✅ codesign command is available"
# Use the same signing identity as the app signing process
SIGN_IDENTITY="${SIGN_IDENTITY:-Developer ID Application}"
echo "Target signing identity: '$SIGN_IDENTITY'"
# Check if we're in CI and have a specific keychain
KEYCHAIN_OPTS=""
if [ -n "${KEYCHAIN_NAME:-}" ]; then
echo "Using keychain: $KEYCHAIN_NAME"
KEYCHAIN_OPTS="--keychain $KEYCHAIN_NAME"
else
echo "No specific keychain specified, using default"
fi
# Try to find a valid signing identity
IDENTITY_CHECK_CMD="security find-identity -v -p codesigning"
if [ -n "${KEYCHAIN_NAME:-}" ]; then
IDENTITY_CHECK_CMD="$IDENTITY_CHECK_CMD $KEYCHAIN_NAME"
echo "Full identity check command: $IDENTITY_CHECK_CMD"
fi
echo ""
echo "=== Full Identity Check Output ==="
echo "Running: $IDENTITY_CHECK_CMD"
IDENTITY_OUTPUT=$($IDENTITY_CHECK_CMD 2>&1) || true
echo "Raw output:"
echo "$IDENTITY_OUTPUT"
echo "=== End Identity Check Output ==="
# Count valid identities
VALID_COUNT=$(echo "$IDENTITY_OUTPUT" | grep -c "valid identities found" || echo "0")
echo "Valid identities found: $VALID_COUNT"
# Check if any signing identity is available
if echo "$IDENTITY_OUTPUT" | grep -q "valid identities found" && ! echo "$IDENTITY_OUTPUT" | grep -q "0 valid identities found"; then
echo "✅ At least one valid signing identity found"
# Show all identities
echo "All available identities:"
echo "$IDENTITY_OUTPUT" | grep -E "^\s*[0-9]+\)"
# Check if our specific identity exists
if echo "$IDENTITY_OUTPUT" | grep -q "$SIGN_IDENTITY"; then
echo "✅ Found specific identity: $SIGN_IDENTITY"
echo "Attempting to sign DMG with identity: $SIGN_IDENTITY"
echo "Command: codesign --force --sign \"$SIGN_IDENTITY\" $KEYCHAIN_OPTS \"$DMG_PATH\""
if codesign --force --sign "$SIGN_IDENTITY" $KEYCHAIN_OPTS "$DMG_PATH"; then
echo "✅ DMG signing successful"
else
echo "❌ DMG signing failed"
exit 1
fi
else
echo "❌ Specific identity '$SIGN_IDENTITY' not found"
# Try to use the first available Developer ID Application identity
echo "Searching for any Developer ID Application identity..."
AVAILABLE_IDENTITY=$(echo "$IDENTITY_OUTPUT" | grep "Developer ID Application" | head -1 | sed -E 's/.*"([^"]+)".*/\1/' || echo "")
if [ -n "$AVAILABLE_IDENTITY" ]; then
echo "✅ Found alternative identity: $AVAILABLE_IDENTITY"
echo "Command: codesign --force --sign \"$AVAILABLE_IDENTITY\" $KEYCHAIN_OPTS \"$DMG_PATH\""
if codesign --force --sign "$AVAILABLE_IDENTITY" $KEYCHAIN_OPTS "$DMG_PATH"; then
echo "✅ DMG signing successful with alternative identity"
else
echo "❌ DMG signing failed with alternative identity"
exit 1
fi
else
echo "❌ No Developer ID Application identity found"
echo "⚠️ DMG will not be signed"
fi
fi
else
echo "❌ No valid signing identities available"
echo "⚠️ DMG will not be signed"
echo "This is expected for PR builds where certificates are not imported"
fi
else
echo "❌ codesign command not available"
echo "⚠️ DMG will not be signed"
fi
echo "=== End Environment Debug Information ==="
# Verify DMG
echo "Verifying DMG..."
hdiutil verify "$DMG_PATH"
echo "DMG created successfully: $DMG_PATH"

68
scripts/extract-build-number.sh Executable file
View file

@ -0,0 +1,68 @@
#!/bin/bash
#
# Extract build number from a VibeTunnel DMG file
#
# This script mounts a DMG, extracts the CFBundleVersion from the app's Info.plist,
# and returns the build number for use in Sparkle appcast generation.
set -euo pipefail
if [ $# -ne 1 ]; then
echo "Usage: $0 <path-to-dmg>"
exit 1
fi
DMG_PATH="$1"
if [ ! -f "$DMG_PATH" ]; then
echo "Error: DMG file not found: $DMG_PATH" >&2
exit 1
fi
# Create temporary directory for mounting
TEMP_DIR=$(mktemp -d)
trap "rm -rf $TEMP_DIR" EXIT
# Mount the DMG
MOUNT_POINT="$TEMP_DIR/mount"
mkdir -p "$MOUNT_POINT"
if ! hdiutil attach "$DMG_PATH" -mountpoint "$MOUNT_POINT" -nobrowse -readonly -quiet 2>/dev/null; then
echo "Error: Failed to mount DMG" >&2
exit 1
fi
# Ensure we unmount on exit
trap "hdiutil detach '$MOUNT_POINT' -quiet 2>/dev/null || true; rm -rf $TEMP_DIR" EXIT
# Find the app bundle
APP_BUNDLE=$(find "$MOUNT_POINT" -name "*.app" -type d | head -1)
if [ -z "$APP_BUNDLE" ]; then
echo "Error: No .app bundle found in DMG" >&2
exit 1
fi
# Extract build number from Info.plist
INFO_PLIST="$APP_BUNDLE/Contents/Info.plist"
if [ ! -f "$INFO_PLIST" ]; then
echo "Error: Info.plist not found in app bundle" >&2
exit 1
fi
# Extract CFBundleVersion using plutil
BUILD_NUMBER=$(plutil -extract CFBundleVersion raw "$INFO_PLIST" 2>/dev/null || echo "")
if [ -z "$BUILD_NUMBER" ]; then
echo "Error: Could not extract CFBundleVersion from Info.plist" >&2
exit 1
fi
# Validate that it's a number
if ! [[ "$BUILD_NUMBER" =~ ^[0-9]+$ ]]; then
echo "Error: Build number is not numeric: $BUILD_NUMBER" >&2
exit 1
fi
echo "$BUILD_NUMBER"

433
scripts/generate-appcast.sh Executable file
View file

@ -0,0 +1,433 @@
#!/bin/bash
#
# Generate appcast XML files with correct file sizes from GitHub releases
#
# This script fetches release information from GitHub and generates
# appcast.xml and appcast-prerelease.xml with accurate file sizes
# to prevent Sparkle download errors.
set -euo pipefail
# Add Sparkle tools to PATH
export PATH="$HOME/.local/bin:$PATH"
# Load GitHub configuration
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
CONFIG_FILE="$(dirname "$SCRIPT_DIR")/.github-config"
if [ -f "$CONFIG_FILE" ]; then
source "$CONFIG_FILE"
fi
# Configuration
GITHUB_USERNAME="${GITHUB_USERNAME:-amantus-ai}"
GITHUB_REPO="${GITHUB_USERNAME}/${GITHUB_REPO:-vibetunnel}"
SPARKLE_PRIVATE_KEY_PATH="private/sparkle_private_key"
# Colors for output
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
NC='\033[0m' # No Color
# Function to print colored output
print_info() {
echo -e "${GREEN}[INFO]${NC} $1" >&2
}
print_warning() {
echo -e "${YELLOW}[WARNING]${NC} $1" >&2
}
print_error() {
echo -e "${RED}[ERROR]${NC} $1" >&2
}
# Function to get file size from URL
get_file_size() {
local url=$1
curl -sI "$url" | grep -i content-length | awk '{print $2}' | tr -d '\r'
}
# Function to check if we have a cached signature
get_cached_signature() {
local filename=$1
local cache_file="$temp_dir/signatures_cache.txt"
# Check if cache file exists and has the signature
if [ -f "$cache_file" ]; then
grep "^$filename:" "$cache_file" | cut -d: -f2 || echo ""
else
echo ""
fi
}
# Function to cache a signature
cache_signature() {
local filename=$1
local signature=$2
local cache_file="$temp_dir/signatures_cache.txt"
if [ -n "$signature" ] && [ "$signature" != "" ]; then
echo "$filename:$signature" >> "$cache_file"
fi
}
# Function to generate EdDSA signature
generate_signature() {
local file_path=$1
local filename=$(basename "$file_path")
# Check if we have a cached signature first
local cached_sig=$(get_cached_signature "$filename")
if [ -n "$cached_sig" ]; then
echo "$cached_sig"
return 0
fi
# Try to use sign_update from Keychain first (preferred method)
if command -v sign_update >/dev/null 2>&1; then
# First try without -f flag to use Keychain
local signature=$(sign_update "$file_path" -p 2>/dev/null)
if [ -n "$signature" ] && [ "$signature" != "-----END PRIVATE KEY-----" ]; then
echo "$signature"
return 0
fi
# If Keychain didn't work and we have a private key file, try that
if [ -f "$SPARKLE_PRIVATE_KEY_PATH" ]; then
signature=$(sign_update "$file_path" -f "$SPARKLE_PRIVATE_KEY_PATH" -p 2>/dev/null)
if [ -n "$signature" ] && [ "$signature" != "-----END PRIVATE KEY-----" ]; then
echo "$signature"
return 0
fi
fi
fi
# Try using the bundled tool from Sparkle framework
local sign_tool="/Applications/Sparkle Test App.app/Contents/Frameworks/Sparkle.framework/Versions/B/Resources/sign_update"
if [ -f "$sign_tool" ]; then
local signature=$("$sign_tool" "$file_path" -p 2>/dev/null)
if [ -n "$signature" ] && [ "$signature" != "-----END PRIVATE KEY-----" ]; then
echo "$signature"
return 0
fi
fi
print_warning "Could not generate signature for $filename"
echo ""
}
# Function to format date for appcast
format_date() {
local date_str=$1
# Convert GitHub date format to RFC 822 format for RSS
date -j -f "%Y-%m-%dT%H:%M:%SZ" "$date_str" "+%a, %d %b %Y %H:%M:%S %z" 2>/dev/null || \
date -d "$date_str" "+%a, %d %b %Y %H:%M:%S %z" 2>/dev/null || \
echo "Wed, 04 Jun 2025 12:00:00 +0000"
}
# Function to extract version and build number from release tag
parse_version() {
local tag=$1
local version=""
local build=""
# Remove 'v' prefix if present
tag=${tag#v}
# For pre-releases like "0.1-beta.1", extract base version
if [[ $tag =~ ^([0-9]+\.[0-9]+)(-.*)?$ ]]; then
version=$tag
else
version=$tag
fi
echo "$version"
}
# Function to create appcast item
create_appcast_item() {
local release_json=$1
local dmg_url=$2
local is_prerelease=$3
# Extract fields with proper fallbacks
local tag=$(echo "$release_json" | jq -r '.tag_name // "unknown"')
local title=$(echo "$release_json" | jq -r '.name // .tag_name // "Release"')
local body=$(echo "$release_json" | jq -r '.body // "Release notes not available"')
local published_at=$(echo "$release_json" | jq -r '.published_at // ""')
# Validate critical fields
if [ "$tag" = "unknown" ] || [ "$tag" = "null" ] || [ -z "$tag" ]; then
print_warning "Invalid tag_name for release, skipping"
return 1
fi
local version_string=$(parse_version "$tag")
# Get DMG asset info using base64 encoding for robustness
local dmg_asset_b64=$(echo "$release_json" | jq -r ".assets[] | select(.browser_download_url == \"$dmg_url\") | {size: .size, name: .name} | @base64" | head -1)
local dmg_size=""
if [ -n "$dmg_asset_b64" ] && [ "$dmg_asset_b64" != "null" ]; then
dmg_size=$(echo "$dmg_asset_b64" | base64 --decode | jq -r '.size // null')
fi
# If size is not in JSON, fetch from HTTP headers
if [ "$dmg_size" = "null" ] || [ -z "$dmg_size" ]; then
print_info "Fetching file size for $dmg_url"
dmg_size=$(get_file_size "$dmg_url")
fi
# Get signature - either from known signatures or by downloading
local dmg_filename=$(basename "$dmg_url")
local signature=""
# Check if we have a cached signature first
local cached_sig=$(get_cached_signature "$dmg_filename")
if [ -n "$cached_sig" ]; then
signature="$cached_sig"
print_info "Using cached signature for $dmg_filename"
else
# We'll download DMG once later for both signature and build number
signature=""
fi
# Extract build number from the DMG
local build_number=""
local temp_dmg="/tmp/$dmg_filename"
# Download DMG if not already present (for both signature and build number)
if [ ! -f "$temp_dmg" ]; then
print_info "Downloading DMG for analysis..."
curl -sL "$dmg_url" -o "$temp_dmg" 2>/dev/null
fi
# Generate signature if we haven't already
if [ -z "$signature" ]; then
signature=$(generate_signature "$temp_dmg")
# Cache the signature for future runs
if [ -n "$signature" ]; then
cache_signature "$dmg_filename" "$signature"
fi
fi
# Extract build number using helper script
if [ -x "$SCRIPT_DIR/extract-build-number.sh" ]; then
build_number=$("$SCRIPT_DIR/extract-build-number.sh" "$temp_dmg" 2>/dev/null || echo "")
elif [ -x "$(dirname "$0")/extract-build-number.sh" ]; then
build_number=$("$(dirname "$0")/extract-build-number.sh" "$temp_dmg" 2>/dev/null || echo "")
else
print_warning "extract-build-number.sh not found - build numbers may be incorrect"
fi
# Fallback to version-based guessing if extraction fails
if [ -z "$build_number" ]; then
print_warning "Could not extract build number from DMG, using fallback"
case "$version_string" in
*-beta.1) build_number="100" ;;
*-beta.2) build_number="101" ;;
*-beta.3) build_number="102" ;;
*-beta.4) build_number="103" ;;
*-rc.1) build_number="110" ;;
*-rc.2) build_number="111" ;;
0.1) build_number="100" ;;
*) build_number="1" ;;
esac
fi
# Clean up temp DMG
rm -f "$temp_dmg"
# Generate description using local changelog
local description="<h2>$title</h2>"
if [ "$is_prerelease" = "true" ]; then
description+="<p><strong>Pre-release version</strong></p>"
fi
# Try to get changelog from local CHANGELOG.md using changelog-to-html.sh
local changelog_html=""
local changelog_script="$(dirname "$SCRIPT_DIR")/scripts/changelog-to-html.sh"
local changelog_file="$(dirname "$SCRIPT_DIR")/CHANGELOG.md"
if [ -x "$changelog_script" ] && [ -f "$changelog_file" ]; then
# Extract version number from tag (remove 'v' prefix)
local version_for_changelog="${version_string}"
changelog_html=$("$changelog_script" "$version_for_changelog" "$changelog_file" 2>/dev/null || echo "")
# If that fails, try with the base version for pre-releases
if [ -z "$changelog_html" ] && [[ "$version_for_changelog" =~ ^([0-9]+\.[0-9]+\.[0-9]+) ]]; then
local base_version="${BASH_REMATCH[1]}"
changelog_html=$("$changelog_script" "$base_version" "$changelog_file" 2>/dev/null || echo "")
fi
fi
# Use changelog if available, otherwise fall back to GitHub release body
if [ -n "$changelog_html" ]; then
description+="<div>$changelog_html</div>"
else
# Fall back to GitHub release body (escaped for XML safety)
local clean_body
clean_body=$(echo "$body" | sed 's/&/\&amp;/g; s/</\&lt;/g; s/>/\&gt;/g; s/"/\&quot;/g; s/'"'"'/\&#39;/g')
if [ -n "$clean_body" ] && [ "$clean_body" != "Release notes not available" ]; then
local formatted_body=$(echo "$clean_body" | head -5 | sed 's/^[[:space:]]*//; s/[[:space:]]*$//; /^$/d' | sed 's/^/<p>/; s/$/<\/p>/')
description+="<div>$formatted_body</div>"
else
description+="<p>Release notes not available</p>"
fi
fi
# Generate the item XML
cat << EOF
<item>
<title>$title</title>
<link>$dmg_url</link>
<sparkle:version>$build_number</sparkle:version>
<sparkle:shortVersionString>$version_string</sparkle:shortVersionString>
<description><![CDATA[
$description
]]></description>
<pubDate>$(format_date "$published_at")</pubDate>
<enclosure
url="$dmg_url"
length="$dmg_size"
type="application/octet-stream"
sparkle:edSignature="$signature"
/>
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
</item>
EOF
}
# Main function
main() {
print_info "Generating appcast files for $GITHUB_REPO"
# Create temporary directory
local temp_dir=$(mktemp -d)
trap "rm -rf $temp_dir" EXIT
# Fetch all releases from GitHub with error handling
print_info "Fetching releases from GitHub..."
local releases
if ! releases=$(gh api "repos/$GITHUB_REPO/releases" --paginate 2>/dev/null); then
print_error "Failed to fetch releases from GitHub. Please check your GitHub CLI authentication and network connection."
exit 1
fi
if [ -z "$releases" ] || [ "$releases" = "[]" ]; then
print_warning "No releases found for repository $GITHUB_REPO"
exit 0
fi
# Separate stable and pre-releases
local stable_releases=$(echo "$releases" | jq -c '.[] | select(.prerelease == false)')
local pre_releases=$(echo "$releases" | jq -c '.[] | select(.prerelease == true)')
# Generate stable appcast
print_info "Generating appcast.xml..."
cat > appcast.xml << 'EOF'
<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" xmlns:dc="http://purl.org/dc/elements/1.1/">
<channel>
<title>VibeTunnel Updates</title>
<link>https://github.com/amantus-ai/vibetunnel</link>
<description>VibeTunnel automatic updates feed</description>
<language>en</language>
EOF
# Add stable releases to appcast
while IFS= read -r release; do
[ -z "$release" ] && continue
# Find DMG asset using base64 encoding for robustness
local dmg_asset_b64=$(echo "$release" | jq -r '.assets[] | select(.name | endswith(".dmg")) | {url: .browser_download_url, name: .name} | @base64' | head -1)
if [ -n "$dmg_asset_b64" ] && [ "$dmg_asset_b64" != "null" ]; then
local dmg_url=$(echo "$dmg_asset_b64" | base64 --decode | jq -r '.url')
if [ -n "$dmg_url" ] && [ "$dmg_url" != "null" ]; then
if create_appcast_item "$release" "$dmg_url" "false" >> appcast.xml; then
print_info "Added stable release: $(echo "$release" | jq -r '.tag_name')"
else
print_warning "Failed to create item for stable release: $(echo "$release" | jq -r '.tag_name')"
fi
fi
else
print_warning "No DMG asset found for stable release: $(echo "$release" | jq -r '.tag_name // "unknown"')"
fi
done <<< "$stable_releases"
echo " </channel>" >> appcast.xml
echo "</rss>" >> appcast.xml
# Generate pre-release appcast
print_info "Generating appcast-prerelease.xml..."
cat > appcast-prerelease.xml << 'EOF'
<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" xmlns:dc="http://purl.org/dc/elements/1.1/">
<channel>
<title>VibeTunnel Pre-release Updates</title>
<link>https://github.com/amantus-ai/vibetunnel</link>
<description>VibeTunnel pre-release and beta updates feed</description>
<language>en</language>
EOF
# Add pre-releases to appcast
while IFS= read -r release; do
[ -z "$release" ] && continue
# Find DMG asset using base64 encoding for robustness
local dmg_asset_b64=$(echo "$release" | jq -r '.assets[] | select(.name | endswith(".dmg")) | {url: .browser_download_url, name: .name} | @base64' | head -1)
if [ -n "$dmg_asset_b64" ] && [ "$dmg_asset_b64" != "null" ]; then
local dmg_url=$(echo "$dmg_asset_b64" | base64 --decode | jq -r '.url')
if [ -n "$dmg_url" ] && [ "$dmg_url" != "null" ]; then
if create_appcast_item "$release" "$dmg_url" "true" >> appcast-prerelease.xml; then
print_info "Added pre-release: $(echo "$release" | jq -r '.tag_name')"
else
print_warning "Failed to create item for pre-release: $(echo "$release" | jq -r '.tag_name')"
fi
fi
else
print_warning "No DMG asset found for pre-release: $(echo "$release" | jq -r '.tag_name // "unknown"')"
fi
done <<< "$pre_releases"
# Also add stable releases to pre-release feed
while IFS= read -r release; do
[ -z "$release" ] && continue
# Find DMG asset using base64 encoding for robustness
local dmg_asset_b64=$(echo "$release" | jq -r '.assets[] | select(.name | endswith(".dmg")) | {url: .browser_download_url, name: .name} | @base64' | head -1)
if [ -n "$dmg_asset_b64" ] && [ "$dmg_asset_b64" != "null" ]; then
local dmg_url=$(echo "$dmg_asset_b64" | base64 --decode | jq -r '.url')
if [ -n "$dmg_url" ] && [ "$dmg_url" != "null" ]; then
if create_appcast_item "$release" "$dmg_url" "false" >> appcast-prerelease.xml; then
print_info "Added stable release to pre-release feed: $(echo "$release" | jq -r '.tag_name')"
else
print_warning "Failed to create item for stable release in pre-release feed: $(echo "$release" | jq -r '.tag_name')"
fi
fi
else
print_warning "No DMG asset found for stable release in pre-release feed: $(echo "$release" | jq -r '.tag_name // "unknown"')"
fi
done <<< "$stable_releases"
echo " </channel>" >> appcast-prerelease.xml
echo "</rss>" >> appcast-prerelease.xml
print_info "✅ Appcast files generated successfully!"
print_info " - appcast.xml (stable releases only)"
print_info " - appcast-prerelease.xml (all releases)"
# Validate the generated files
if command -v xmllint >/dev/null 2>&1; then
print_info "Validating XML..."
xmllint --noout appcast.xml && print_info " ✓ appcast.xml is valid"
xmllint --noout appcast-prerelease.xml && print_info " ✓ appcast-prerelease.xml is valid"
fi
}
# Run main function
main "$@"

79
scripts/generate-xcproj.sh Executable file
View file

@ -0,0 +1,79 @@
#!/bin/bash
# =============================================================================
# VibeTunnel Xcode Project Generation Script
# =============================================================================
#
# This script generates the Xcode project and workspace using Tuist, with
# automatic patches applied for Swift 6 Sendable compliance. It's essential
# to run this script after any changes to Project.swift or Tuist.swift.
#
# USAGE:
# ./scripts/generate-xcproj.sh
#
# FEATURES:
# - Runs Tuist project generation silently (no Xcode restart)
# - Applies Swift 6 Sendable compliance patches
# - Allows regeneration while Xcode is open
#
# DEPENDENCIES:
# - Tuist (project generation tool)
# - Xcode (for opening the workspace)
#
# FILES GENERATED:
# - VibeTunnel.xcodeproj/ (Xcode project)
# - VibeTunnel.xcworkspace/ (Xcode workspace)
# - Derived/ (generated sources and Info.plist files)
#
# EXAMPLES:
# ./scripts/generate-xcproj.sh
#
# NOTES:
# - Always run this after modifying Project.swift or Tuist.swift
# - The script includes patches for Swift 6 compliance
# - Generated files are partially tracked in git for CI compatibility
#
# =============================================================================
set -e
# Change to the project directory
cd "$(dirname "$0")/.."
# Skip Xcode quit/restart to allow silent regeneration while Xcode is open
echo "Generating Xcode project with Tuist..."
tuist generate --no-open
echo "Patching generated files for Swift 6 Sendable compliance..."
# Function to patch Info.plist accessor files
patch_info_plist_accessors() {
local file=$1
if [ -f "$file" ]; then
echo "Patching $file..."
# Replace [String: Any] with [String: Bool] for NSAppTransportSecurity
sed -i '' 's/\[String: Any\]/[String: Bool]/g' "$file"
# Replace [[String: Any]] with [[String: Sendable]] for arrays
sed -i '' 's/\[\[String: Any\]\]/[[String: Sendable]]/g' "$file"
# Update the ResourceLoader struct to handle typed dictionaries
# Replace dictionary<String, Any> with dictionary<String, Bool>
sed -i '' 's/dictionary<String, Any>/dictionary<String, Bool>/g' "$file"
# Replace arrayOfDictionaries<String, Any> with arrayOfDictionaries<String, Sendable>
sed -i '' 's/arrayOfDictionaries<String, Any>/arrayOfDictionaries<String, Sendable>/g' "$file"
fi
}
# Find and patch all Info.plist accessor files
find . -path "*/Derived/InfoPlists+*" -name "*.swift" | while read -r file; do
patch_info_plist_accessors "$file"
done
# Skip opening workspace to allow silent regeneration
echo "✅ Xcode project generated and patched successfully!"

313
scripts/notarize-app.sh Executable file
View file

@ -0,0 +1,313 @@
#!/bin/bash
# notarize-app.sh - Complete notarization script for VibeTunnel with Sparkle
# Handles hardened runtime, proper signing of all components, and notarization
set -eo pipefail
# ============================================================================
# Configuration
# ============================================================================
# Get the script and project directories
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
log() {
echo "[$(date "+%Y-%m-%d %H:%M:%S")] $1"
}
error() {
echo "[$(date "+%Y-%m-%d %H:%M:%S")] ❌ ERROR: $1" >&2
exit 1
}
success() {
echo "[$(date "+%Y-%m-%d %H:%M:%S")] ✅ $1"
}
APP_BUNDLE="${1:-build/Build/Products/Release/VibeTunnel.app}"
SIGN_IDENTITY="Developer ID Application: Peter Steinberger (Y5PE65HELJ)"
TIMEOUT_MINUTES=30
# Check if app bundle exists
if [ ! -d "$APP_BUNDLE" ]; then
error "App bundle not found at $APP_BUNDLE"
fi
log "Starting complete notarization process for $APP_BUNDLE"
# Check required environment variables for notarization
if [ -z "$APP_STORE_CONNECT_API_KEY_P8" ] || [ -z "$APP_STORE_CONNECT_KEY_ID" ] || [ -z "$APP_STORE_CONNECT_ISSUER_ID" ]; then
error "Required environment variables not set. Need APP_STORE_CONNECT_API_KEY_P8, APP_STORE_CONNECT_KEY_ID, APP_STORE_CONNECT_ISSUER_ID"
fi
# Create temporary API key file
API_KEY_FILE=$(mktemp)
echo "$APP_STORE_CONNECT_API_KEY_P8" | sed 's/\\n/\n/g' > "$API_KEY_FILE"
cleanup() {
rm -f "$API_KEY_FILE" "/tmp/VibeTunnel_notarize.zip"
}
trap cleanup EXIT
# ============================================================================
# Create Entitlements Files
# ============================================================================
create_entitlements() {
local entitlements_file="$1"
local is_xpc_service="$2"
cat > "$entitlements_file" << 'EOF'
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.cs.allow-jit</key>
<false/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<false/>
<key>com.apple.security.cs.disable-executable-page-protection</key>
<false/>
<key>com.apple.security.cs.disable-library-validation</key>
<false/>
<key>com.apple.security.hardened-runtime</key>
<true/>
EOF
if [ "$is_xpc_service" = "true" ]; then
cat >> "$entitlements_file" << 'EOF'
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>
<key>com.apple.security.files.user-selected.read-write</key>
<true/>
<key>com.apple.security.temporary-exception.mach-lookup.global-name</key>
<array>
<string>com.amantus.vibetunnel-spks</string>
<string>com.amantus.vibetunnel-spkd</string>
</array>
EOF
fi
cat >> "$entitlements_file" << 'EOF'
</dict>
</plist>
EOF
}
# Create entitlements files
MAIN_ENTITLEMENTS="/tmp/main_entitlements.plist"
XPC_ENTITLEMENTS="/tmp/xpc_entitlements.plist"
# Use actual VibeTunnel entitlements for the main app
if [ -f "VibeTunnel/VibeTunnel.entitlements" ]; then
cp "VibeTunnel/VibeTunnel.entitlements" "$MAIN_ENTITLEMENTS"
elif [ -f "$PROJECT_ROOT/VibeTunnel/VibeTunnel.entitlements" ]; then
cp "$PROJECT_ROOT/VibeTunnel/VibeTunnel.entitlements" "$MAIN_ENTITLEMENTS"
else
log "Warning: VibeTunnel.entitlements not found, using default entitlements"
create_entitlements "$MAIN_ENTITLEMENTS" "false"
fi
create_entitlements "$XPC_ENTITLEMENTS" "true"
# ============================================================================
# Signing Functions
# ============================================================================
sign_binary() {
local binary="$1"
local entitlements="$2"
local description="$3"
log "Signing $description: $(basename "$binary")"
# Add keychain option if available
keychain_opts=""
if [ -n "${KEYCHAIN_NAME:-}" ]; then
keychain_opts="--keychain $KEYCHAIN_NAME"
fi
codesign \
--force \
--sign "$SIGN_IDENTITY" \
--entitlements "$entitlements" \
--options runtime \
--timestamp \
$keychain_opts \
"$binary"
}
sign_app_bundle() {
local bundle="$1"
local entitlements="$2"
local description="$3"
log "Signing $description: $(basename "$bundle")"
# Add keychain option if available
keychain_opts=""
if [ -n "${KEYCHAIN_NAME:-}" ]; then
keychain_opts="--keychain $KEYCHAIN_NAME"
fi
codesign \
--force \
--sign "$SIGN_IDENTITY" \
--entitlements "$entitlements" \
--options runtime \
--timestamp \
$keychain_opts \
"$bundle"
}
# ============================================================================
# Deep Signing Process
# ============================================================================
log "Performing deep signing with proper Sparkle framework handling..."
# 0. Fix Sparkle XPC services for sandbox
log "Fixing Sparkle XPC services for sandboxed operation..."
if [ -x "$SCRIPT_DIR/fix-sparkle-sandbox.sh" ]; then
"$SCRIPT_DIR/fix-sparkle-sandbox.sh" "$APP_BUNDLE" || log "Warning: Sparkle sandbox fix failed (continuing anyway)"
else
log "Warning: fix-sparkle-sandbox.sh not found or not executable"
fi
# 1. Sign Sparkle components manually per documentation
# https://sparkle-project.org/documentation/sandboxing/#code-signing
log "Signing Sparkle components per documentation..."
# Add keychain option if available
keychain_opts=""
if [ -n "${KEYCHAIN_NAME:-}" ]; then
keychain_opts="--keychain $KEYCHAIN_NAME"
fi
# Sign XPC services (directories, not files)
if [ -d "$APP_BUNDLE/Contents/Frameworks/Sparkle.framework/Versions/B/XPCServices/Installer.xpc" ]; then
codesign -f -s "$SIGN_IDENTITY" -o runtime $keychain_opts "$APP_BUNDLE/Contents/Frameworks/Sparkle.framework/Versions/B/XPCServices/Installer.xpc"
log "Signed Installer.xpc"
fi
if [ -d "$APP_BUNDLE/Contents/Frameworks/Sparkle.framework/Versions/B/XPCServices/Downloader.xpc" ]; then
# For Sparkle versions >= 2.6, preserve entitlements
codesign -f -s "$SIGN_IDENTITY" -o runtime --preserve-metadata=entitlements $keychain_opts "$APP_BUNDLE/Contents/Frameworks/Sparkle.framework/Versions/B/XPCServices/Downloader.xpc"
log "Signed Downloader.xpc"
fi
# Sign other Sparkle components
if [ -f "$APP_BUNDLE/Contents/Frameworks/Sparkle.framework/Versions/B/Autoupdate" ]; then
codesign -f -s "$SIGN_IDENTITY" -o runtime $keychain_opts "$APP_BUNDLE/Contents/Frameworks/Sparkle.framework/Versions/B/Autoupdate"
log "Signed Autoupdate"
fi
if [ -d "$APP_BUNDLE/Contents/Frameworks/Sparkle.framework/Versions/B/Updater.app" ]; then
codesign -f -s "$SIGN_IDENTITY" -o runtime $keychain_opts "$APP_BUNDLE/Contents/Frameworks/Sparkle.framework/Versions/B/Updater.app"
log "Signed Updater.app"
fi
# Finally sign the framework itself
codesign -f -s "$SIGN_IDENTITY" -o runtime $keychain_opts "$APP_BUNDLE/Contents/Frameworks/Sparkle.framework"
log "Signed Sparkle.framework"
# 2. Sparkle framework is already signed above per documentation
# 3. Sign other frameworks
log "Signing other frameworks..."
find "$APP_BUNDLE/Contents/Frameworks" -name "*.framework" -not -path "*Sparkle*" -type d | while read framework; do
framework_binary="$framework/$(basename "$framework" .framework)"
if [ -f "$framework_binary" ]; then
sign_binary "$framework_binary" "$MAIN_ENTITLEMENTS" "Framework binary"
fi
keychain_opts=""
if [ -n "${KEYCHAIN_NAME:-}" ]; then
keychain_opts="--keychain $KEYCHAIN_NAME"
fi
codesign \
--force \
--sign "$SIGN_IDENTITY" \
--options runtime \
--timestamp \
$keychain_opts \
"$framework"
done
# 4. Sign helper tools and executables
log "Signing helper tools..."
find "$APP_BUNDLE/Contents" -type f -perm +111 -not -path "*/MacOS/*" -not -path "*/Frameworks/*" | while read executable; do
sign_binary "$executable" "$MAIN_ENTITLEMENTS" "Helper executable"
done
# 5. Finally, sign the main app bundle
log "Signing main app bundle..."
keychain_opts=""
if [ -n "${KEYCHAIN_NAME:-}" ]; then
keychain_opts="--keychain $KEYCHAIN_NAME"
fi
codesign \
--force \
--sign "$SIGN_IDENTITY" \
--entitlements "$MAIN_ENTITLEMENTS" \
--options runtime \
--timestamp \
$keychain_opts \
"$APP_BUNDLE"
# ============================================================================
# Notarization
# ============================================================================
# Check if notarytool is available
if ! xcrun --find notarytool &> /dev/null; then
error "notarytool not found. Please ensure Xcode 13+ is installed"
fi
log "Using modern notarytool for notarization"
# Create ZIP for notarization
ZIP_PATH="/tmp/VibeTunnel_notarize.zip"
log "Creating ZIP archive for notarization..."
if ! ditto -c -k --keepParent "$APP_BUNDLE" "$ZIP_PATH"; then
error "Failed to create ZIP archive"
fi
# Submit for notarization using notarytool
log "Submitting app for notarization..."
SUBMIT_CMD="xcrun notarytool submit \"$ZIP_PATH\" --key \"$API_KEY_FILE\" --key-id \"$APP_STORE_CONNECT_KEY_ID\" --issuer \"$APP_STORE_CONNECT_ISSUER_ID\" --wait --timeout ${TIMEOUT_MINUTES}m"
# Run submission with timeout
if ! eval "$SUBMIT_CMD"; then
error "Notarization submission failed"
fi
success "Notarization completed successfully"
# Staple the notarization ticket
log "Stapling notarization ticket to app bundle..."
if ! xcrun stapler staple "$APP_BUNDLE"; then
error "Failed to staple notarization ticket"
fi
# Verify the stapling
log "Verifying stapled notarization ticket..."
if ! xcrun stapler validate "$APP_BUNDLE"; then
error "Failed to verify stapled ticket"
fi
# Test with spctl to ensure it passes Gatekeeper
log "Testing with spctl (Gatekeeper)..."
if spctl -a -t exec -vv "$APP_BUNDLE" 2>&1; then
success "spctl verification passed - app will run without warnings"
else
log "⚠️ spctl verification failed - app may show security warnings"
fi
success "Notarization and stapling completed successfully"
# Clean up temporary files
rm -f "$MAIN_ENTITLEMENTS" "$XPC_ENTITLEMENTS"

322
scripts/preflight-check.sh Executable file
View file

@ -0,0 +1,322 @@
#!/bin/bash
# =============================================================================
# VibeTunnel Pre-flight Check Script
# =============================================================================
#
# This script validates that everything is ready for a VibeTunnel release by
# performing comprehensive checks on git status, build configuration, tools,
# certificates, and the IS_PRERELEASE_BUILD system.
#
# USAGE:
# ./scripts/preflight-check.sh
#
# VALIDATION CHECKS:
# - Git repository status (clean working tree, main branch, synced)
# - Version information and build number validation
# - Required development tools (Tuist, GitHub CLI, Sparkle tools)
# - Code signing certificates and notarization credentials
# - Sparkle configuration (keys, appcast files)
# - IS_PRERELEASE_BUILD system configuration
#
# EXIT CODES:
# 0 All checks passed - ready to release
# 1 Some checks failed - fix issues before releasing
#
# DEPENDENCIES:
# - git (repository management)
# - gh (GitHub CLI)
# - tuist (project generation)
# - sign_update (Sparkle EdDSA signing)
# - xcbeautify (optional, build output formatting)
# - security (keychain access for certificates)
# - xmllint (appcast validation)
#
# ENVIRONMENT VARIABLES:
# APP_STORE_CONNECT_API_KEY_P8 App Store Connect API key (for notarization)
# APP_STORE_CONNECT_KEY_ID API Key ID
# APP_STORE_CONNECT_ISSUER_ID API Key Issuer ID
#
# EXAMPLES:
# ./scripts/preflight-check.sh
#
# =============================================================================
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
# Colors for output
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
NC='\033[0m' # No Color
# Track if any checks fail
CHECKS_PASSED=true
echo "🔍 VibeTunnel Release Pre-flight Check"
echo "===================================="
echo ""
# Function to print check results
check_pass() {
echo -e "${GREEN}✅ PASS${NC}: $1"
}
check_fail() {
echo -e "${RED}❌ FAIL${NC}: $1"
CHECKS_PASSED=false
}
check_warn() {
echo -e "${YELLOW}⚠️ WARN${NC}: $1"
}
# 1. Check Git status
echo "📌 Git Status:"
# Refresh the index to avoid false positives
git update-index --refresh >/dev/null 2>&1 || true
if git diff-index --quiet HEAD -- 2>/dev/null; then
check_pass "Working directory is clean"
else
check_fail "Uncommitted changes detected"
git status --short
fi
# Check if on main branch
CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD)
if [[ "$CURRENT_BRANCH" == "main" ]]; then
check_pass "On main branch"
else
check_warn "Not on main branch (current: $CURRENT_BRANCH)"
fi
# Check if up to date with remote
git fetch origin main --quiet
LOCAL=$(git rev-parse HEAD)
REMOTE=$(git rev-parse origin/main)
if [[ "$LOCAL" == "$REMOTE" ]]; then
check_pass "Up to date with origin/main"
else
check_fail "Not synced with origin/main"
fi
echo ""
# 2. Check version information
echo "📌 Version Information:"
MARKETING_VERSION=$(grep 'MARKETING_VERSION' "$PROJECT_ROOT/Project.swift" | sed 's/.*"MARKETING_VERSION": "\(.*\)".*/\1/')
BUILD_NUMBER=$(grep 'CURRENT_PROJECT_VERSION' "$PROJECT_ROOT/Project.swift" | sed 's/.*"CURRENT_PROJECT_VERSION": "\(.*\)".*/\1/')
echo " Marketing Version: $MARKETING_VERSION"
echo " Build Number: $BUILD_NUMBER"
# Check for Info.plist overrides
if grep "CFBundleShortVersionString" "$PROJECT_ROOT/Project.swift" | grep -v "MARKETING_VERSION" | grep -q .; then
check_fail "Info.plist has version overrides - remove them"
else
check_pass "No Info.plist version overrides"
fi
echo ""
# 3. Check build numbers
echo "📌 Build Number Validation:"
USED_BUILD_NUMBERS=""
if [[ -f "$PROJECT_ROOT/appcast.xml" ]]; then
APPCAST_BUILDS=$(grep -E '<sparkle:version>[0-9]+</sparkle:version>' "$PROJECT_ROOT/appcast.xml" 2>/dev/null | sed 's/.*<sparkle:version>\([0-9]*\)<\/sparkle:version>.*/\1/' | tr '\n' ' ' || true)
USED_BUILD_NUMBERS+="$APPCAST_BUILDS"
fi
if [[ -f "$PROJECT_ROOT/appcast-prerelease.xml" ]]; then
PRERELEASE_BUILDS=$(grep -E '<sparkle:version>[0-9]+</sparkle:version>' "$PROJECT_ROOT/appcast-prerelease.xml" 2>/dev/null | sed 's/.*<sparkle:version>\([0-9]*\)<\/sparkle:version>.*/\1/' | tr '\n' ' ' || true)
USED_BUILD_NUMBERS+="$PRERELEASE_BUILDS"
fi
# Find highest build number
HIGHEST_BUILD=0
for EXISTING_BUILD in $USED_BUILD_NUMBERS; do
if [[ "$EXISTING_BUILD" -gt "$HIGHEST_BUILD" ]]; then
HIGHEST_BUILD=$EXISTING_BUILD
fi
done
if [[ -z "$USED_BUILD_NUMBERS" ]]; then
check_pass "No existing builds found"
else
echo " Existing builds: $USED_BUILD_NUMBERS"
echo " Highest build: $HIGHEST_BUILD"
# Check for duplicates
for EXISTING_BUILD in $USED_BUILD_NUMBERS; do
if [[ "$BUILD_NUMBER" == "$EXISTING_BUILD" ]]; then
check_fail "Build number $BUILD_NUMBER already exists!"
fi
done
# Check if monotonically increasing
if [[ "$BUILD_NUMBER" -gt "$HIGHEST_BUILD" ]]; then
check_pass "Build number $BUILD_NUMBER is valid (> $HIGHEST_BUILD)"
else
check_fail "Build number must be > $HIGHEST_BUILD"
fi
fi
echo ""
# 4. Check required tools
echo "📌 Required Tools:"
# GitHub CLI
if command -v gh &> /dev/null; then
check_pass "GitHub CLI (gh) installed"
if gh auth status &> /dev/null; then
check_pass "GitHub CLI authenticated"
else
check_fail "GitHub CLI not authenticated - run: gh auth login"
fi
else
check_fail "GitHub CLI not installed - run: brew install gh"
fi
# Tuist
if command -v tuist &> /dev/null; then
check_pass "Tuist installed"
else
check_fail "Tuist not installed - run: curl -Ls https://install.tuist.io | bash"
fi
# Sparkle tools
if [[ -f "$HOME/.local/bin/sign_update" ]]; then
check_pass "Sparkle sign_update installed"
else
check_fail "Sparkle tools not installed - see RELEASE.md"
fi
# xcbeautify (optional but recommended)
if command -v xcbeautify &> /dev/null; then
check_pass "xcbeautify installed"
else
check_warn "xcbeautify not installed (optional) - run: brew install xcbeautify"
fi
echo ""
# 5. Check signing configuration
echo "📌 Signing Configuration:"
# Check for Developer ID certificate
if security find-identity -v -p codesigning | grep -q "Developer ID Application"; then
check_pass "Developer ID certificate found"
else
check_fail "No Developer ID certificate found"
fi
# Check for notarization credentials
if [[ -n "${APP_STORE_CONNECT_API_KEY_P8:-}" ]]; then
check_pass "Notarization API key configured"
else
check_warn "Notarization API key not in environment"
fi
echo ""
# 6. Check Sparkle configuration
echo "📌 Sparkle Configuration:"
# Check public key
PUBLIC_KEY=$(grep 'SUPublicEDKey' "$PROJECT_ROOT/Project.swift" | sed 's/.*"SUPublicEDKey": "\(.*\)".*/\1/')
if [[ -n "$PUBLIC_KEY" ]]; then
check_pass "Sparkle public key configured"
else
check_fail "Sparkle public key not found in Project.swift"
fi
# Check private key in keychain
export PATH="$HOME/.local/bin:$PATH"
if command -v generate_keys &> /dev/null && generate_keys -p &>/dev/null; then
check_pass "Sparkle private key found in Keychain"
else
check_fail "Sparkle private key not found in Keychain - run: generate_keys"
fi
echo ""
# 7. Check appcast files
echo "📌 Appcast Files:"
if [[ -f "$PROJECT_ROOT/appcast.xml" ]]; then
if xmllint --noout "$PROJECT_ROOT/appcast.xml" 2>/dev/null; then
check_pass "appcast.xml is valid XML"
else
check_fail "appcast.xml has XML errors"
fi
else
check_warn "appcast.xml not found (OK if no stable releases yet)"
fi
if [[ -f "$PROJECT_ROOT/appcast-prerelease.xml" ]]; then
if xmllint --noout "$PROJECT_ROOT/appcast-prerelease.xml" 2>/dev/null; then
check_pass "appcast-prerelease.xml is valid XML"
else
check_fail "appcast-prerelease.xml has XML errors"
fi
else
check_warn "appcast-prerelease.xml not found (OK if no pre-releases yet)"
fi
echo ""
# 8. Check IS_PRERELEASE_BUILD Configuration
echo "📌 IS_PRERELEASE_BUILD System:"
# Check if IS_PRERELEASE_BUILD is configured in Project.swift
if grep -q '"IS_PRERELEASE_BUILD".*"\$(IS_PRERELEASE_BUILD)"' "$PROJECT_ROOT/Project.swift"; then
check_pass "IS_PRERELEASE_BUILD flag configured in Project.swift"
else
check_fail "IS_PRERELEASE_BUILD flag missing from Project.swift Info.plist section"
fi
# Check if UpdateChannel.swift has the flag detection logic
if grep -q "Bundle.main.object.*IS_PRERELEASE_BUILD" "$PROJECT_ROOT/VibeTunnel/Core/Models/UpdateChannel.swift"; then
check_pass "UpdateChannel has IS_PRERELEASE_BUILD detection logic"
else
check_fail "UpdateChannel.swift missing IS_PRERELEASE_BUILD flag detection"
fi
# Check if release script sets the environment variable
if grep -q "export IS_PRERELEASE_BUILD=" "$PROJECT_ROOT/scripts/release.sh"; then
check_pass "Release script sets IS_PRERELEASE_BUILD environment variable"
else
check_fail "Release script missing IS_PRERELEASE_BUILD environment variable setup"
fi
# Check if AppBehaviorSettingsManager uses defaultChannel
if grep -q "UpdateChannel.defaultChannel" "$PROJECT_ROOT/VibeTunnel/Core/Services/Settings/AppBehaviorSettingsManager.swift"; then
check_pass "AppBehaviorSettingsManager uses UpdateChannel.defaultChannel()"
else
check_fail "AppBehaviorSettingsManager not using UpdateChannel.defaultChannel() for auto-detection"
fi
echo ""
# 9. Summary
echo "📊 Pre-flight Summary:"
echo "===================="
if [[ "$CHECKS_PASSED" == true ]]; then
echo -e "${GREEN}✅ All critical checks passed!${NC}"
echo ""
echo "Ready to release:"
echo " Version: $MARKETING_VERSION"
echo " Build: $BUILD_NUMBER"
echo ""
echo "Next steps:"
echo " - For beta: ./scripts/release.sh beta 1"
echo " - For stable: ./scripts/release.sh stable"
exit 0
else
echo -e "${RED}❌ Some checks failed. Please fix the issues above.${NC}"
exit 1
fi

364
scripts/release.sh Executable file
View file

@ -0,0 +1,364 @@
#!/bin/bash
# =============================================================================
# VibeTunnel Automated Release Script
# =============================================================================
#
# This script handles the complete end-to-end release process for VibeTunnel,
# including building, signing, notarization, DMG creation, GitHub releases,
# and appcast updates. It supports both stable and pre-release versions.
#
# USAGE:
# ./scripts/release.sh <type> [number]
#
# ARGUMENTS:
# type Release type: stable, beta, alpha, rc
# number Pre-release number (required for beta/alpha/rc)
#
# FEATURES:
# - Complete build and release automation
# - Automatic IS_PRERELEASE_BUILD flag handling
# - Code signing and notarization
# - DMG creation with signing
# - GitHub release creation with assets
# - Appcast XML generation and updates
# - Git tag management and commit automation
# - Comprehensive error checking and validation
#
# ENVIRONMENT VARIABLES:
# APP_STORE_CONNECT_API_KEY_P8 App Store Connect API key (for notarization)
# APP_STORE_CONNECT_KEY_ID API Key ID
# APP_STORE_CONNECT_ISSUER_ID API Key Issuer ID
#
# DEPENDENCIES:
# - preflight-check.sh (validates release readiness)
# - generate-xcproj.sh (Tuist project generation)
# - build.sh (application building)
# - sign-and-notarize.sh (code signing and notarization)
# - create-dmg.sh (DMG creation)
# - generate-appcast.sh (appcast updates)
# - GitHub CLI (gh) for release creation
# - Sparkle tools (sign_update) for EdDSA signatures
#
# RELEASE PROCESS:
# 1. Pre-flight validation (git status, tools, certificates)
# 2. Xcode project generation and commit if needed
# 3. Application building with appropriate flags
# 4. Code signing and notarization
# 5. DMG creation and signing
# 6. GitHub release creation with assets
# 7. Appcast XML generation and updates
# 8. Git commits and pushes
#
# EXAMPLES:
# ./scripts/release.sh stable # Create stable release
# ./scripts/release.sh beta 1 # Create beta.1 release
# ./scripts/release.sh alpha 2 # Create alpha.2 release
# ./scripts/release.sh rc 1 # Create rc.1 release
#
# OUTPUT:
# - GitHub release at: https://github.com/amantus-ai/vibetunnel/releases
# - Signed DMG file in build/ directory
# - Updated appcast.xml and appcast-prerelease.xml files
# - Git commits and tags pushed to repository
#
# =============================================================================
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
# Color codes
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'
# Parse arguments
RELEASE_TYPE="${1:-}"
PRERELEASE_NUMBER="${2:-}"
# Validate arguments
if [[ -z "$RELEASE_TYPE" ]]; then
echo -e "${RED}❌ Error: Release type required${NC}"
echo ""
echo "Usage:"
echo " $0 stable # Create stable release"
echo " $0 beta <number> # Create beta.N release"
echo " $0 alpha <number> # Create alpha.N release"
echo " $0 rc <number> # Create rc.N release"
echo ""
echo "Examples:"
echo " $0 stable"
echo " $0 beta 1"
echo " $0 rc 3"
exit 1
fi
# For pre-releases, validate number
if [[ "$RELEASE_TYPE" != "stable" ]]; then
if [[ -z "$PRERELEASE_NUMBER" ]]; then
echo -e "${RED}❌ Error: Pre-release number required for $RELEASE_TYPE${NC}"
echo "Example: $0 $RELEASE_TYPE 1"
exit 1
fi
fi
echo -e "${BLUE}🚀 VibeTunnel Automated Release${NC}"
echo "=============================="
echo ""
# Step 1: Run pre-flight check
echo -e "${BLUE}📋 Step 1/7: Running pre-flight check...${NC}"
if ! "$SCRIPT_DIR/preflight-check.sh"; then
echo ""
echo -e "${RED}❌ Pre-flight check failed. Please fix the issues above.${NC}"
exit 1
fi
echo ""
echo -e "${GREEN}✅ Pre-flight check passed!${NC}"
echo ""
# Get version info
MARKETING_VERSION=$(grep 'MARKETING_VERSION' "$PROJECT_ROOT/Project.swift" | sed 's/.*"MARKETING_VERSION": "\(.*\)".*/\1/')
BUILD_NUMBER=$(grep 'CURRENT_PROJECT_VERSION' "$PROJECT_ROOT/Project.swift" | sed 's/.*"CURRENT_PROJECT_VERSION": "\(.*\)".*/\1/')
# Determine release version
if [[ "$RELEASE_TYPE" == "stable" ]]; then
RELEASE_VERSION="$MARKETING_VERSION"
TAG_NAME="v$RELEASE_VERSION"
else
RELEASE_VERSION="$MARKETING_VERSION-$RELEASE_TYPE.$PRERELEASE_NUMBER"
TAG_NAME="v$RELEASE_VERSION"
fi
echo "📦 Preparing release:"
echo " Type: $RELEASE_TYPE"
echo " Version: $RELEASE_VERSION"
echo " Build: $BUILD_NUMBER"
echo " Tag: $TAG_NAME"
echo ""
# Step 2: Clean and generate project
echo -e "${BLUE}📋 Step 2/7: Generating Xcode project...${NC}"
rm -rf "$PROJECT_ROOT/build"
"$SCRIPT_DIR/generate-xcproj.sh"
# Check if Xcode project was modified and commit if needed
if ! git diff --quiet "$PROJECT_ROOT/VibeTunnel.xcodeproj/project.pbxproj"; then
echo "📝 Committing Xcode project changes..."
git add "$PROJECT_ROOT/VibeTunnel.xcodeproj/project.pbxproj"
git commit -m "Update Xcode project for build $BUILD_NUMBER"
echo -e "${GREEN}✅ Xcode project changes committed${NC}"
fi
# Step 3: Build the app
echo ""
echo -e "${BLUE}📋 Step 3/7: Building application...${NC}"
# For pre-release builds, set the environment variable
if [[ "$RELEASE_TYPE" != "stable" ]]; then
echo "📝 Marking build as pre-release..."
export IS_PRERELEASE_BUILD=YES
else
export IS_PRERELEASE_BUILD=NO
fi
"$SCRIPT_DIR/build.sh" --configuration Release
# Verify build
APP_PATH="$PROJECT_ROOT/build/Build/Products/Release/VibeTunnel.app"
if [[ ! -d "$APP_PATH" ]]; then
echo -e "${RED}❌ Build failed - app not found${NC}"
exit 1
fi
# Verify build number
BUILT_VERSION=$(defaults read "$APP_PATH/Contents/Info.plist" CFBundleVersion)
if [[ "$BUILT_VERSION" != "$BUILD_NUMBER" ]]; then
echo -e "${RED}❌ Build number mismatch! Expected $BUILD_NUMBER but got $BUILT_VERSION${NC}"
exit 1
fi
echo -e "${GREEN}✅ Build complete${NC}"
# Step 4: Sign and notarize
echo ""
echo -e "${BLUE}📋 Step 4/7: Signing and notarizing...${NC}"
"$SCRIPT_DIR/sign-and-notarize.sh" --sign-and-notarize
# Step 5: Create DMG
echo ""
echo -e "${BLUE}📋 Step 5/7: Creating DMG...${NC}"
DMG_NAME="VibeTunnel-$RELEASE_VERSION.dmg"
DMG_PATH="$PROJECT_ROOT/build/$DMG_NAME"
"$SCRIPT_DIR/create-dmg.sh" "$APP_PATH" "$DMG_PATH"
if [[ ! -f "$DMG_PATH" ]]; then
echo -e "${RED}❌ DMG creation failed${NC}"
exit 1
fi
echo -e "${GREEN}✅ DMG created: $DMG_NAME${NC}"
# Step 6: Create GitHub release
echo ""
echo -e "${BLUE}📋 Step 6/7: Creating GitHub release...${NC}"
# Check if tag already exists
if git rev-parse "$TAG_NAME" >/dev/null 2>&1; then
echo -e "${YELLOW}⚠️ Tag $TAG_NAME already exists!${NC}"
# Check if a release exists for this tag
if gh release view "$TAG_NAME" >/dev/null 2>&1; then
echo ""
echo "A GitHub release already exists for this tag."
echo "What would you like to do?"
echo " 1) Delete the existing release and tag, then create new ones"
echo " 2) Cancel the release"
echo ""
read -p "Enter your choice (1 or 2): " choice
case $choice in
1)
echo "🗑️ Deleting existing release and tag..."
gh release delete "$TAG_NAME" --yes 2>/dev/null || true
git tag -d "$TAG_NAME"
git push origin :refs/tags/"$TAG_NAME" 2>/dev/null || true
echo -e "${GREEN}✅ Existing release and tag deleted${NC}"
;;
2)
echo -e "${RED}❌ Release cancelled${NC}"
exit 1
;;
*)
echo -e "${RED}❌ Invalid choice. Release cancelled${NC}"
exit 1
;;
esac
else
# Tag exists but no release - just delete the tag
echo "🗑️ Deleting existing tag..."
git tag -d "$TAG_NAME"
git push origin :refs/tags/"$TAG_NAME" 2>/dev/null || true
echo -e "${GREEN}✅ Existing tag deleted${NC}"
fi
fi
# Create and push tag
echo "🏷️ Creating tag $TAG_NAME..."
git tag -a "$TAG_NAME" -m "Release $RELEASE_VERSION (build $BUILD_NUMBER)"
git push origin "$TAG_NAME"
# Create release
echo "📤 Creating GitHub release..."
# Generate release notes from changelog
echo "📝 Generating release notes from changelog..."
CHANGELOG_HTML=""
if [[ -x "$SCRIPT_DIR/changelog-to-html.sh" ]] && [[ -f "$PROJECT_ROOT/CHANGELOG.md" ]]; then
# Extract version for changelog (remove any pre-release suffixes for lookup)
CHANGELOG_VERSION="$RELEASE_VERSION"
if [[ "$CHANGELOG_VERSION" =~ ^([0-9]+\.[0-9]+\.[0-9]+) ]]; then
CHANGELOG_BASE="${BASH_REMATCH[1]}"
# Try full version first, then base version
CHANGELOG_HTML=$("$SCRIPT_DIR/changelog-to-html.sh" "$CHANGELOG_VERSION" "$PROJECT_ROOT/CHANGELOG.md" 2>/dev/null || \
"$SCRIPT_DIR/changelog-to-html.sh" "$CHANGELOG_BASE" "$PROJECT_ROOT/CHANGELOG.md" 2>/dev/null || \
echo "")
fi
fi
# Fallback to basic release notes if changelog extraction fails
if [[ -z "$CHANGELOG_HTML" ]]; then
echo "⚠️ Could not extract changelog, using basic release notes"
RELEASE_NOTES="Release $RELEASE_VERSION (build $BUILD_NUMBER)"
else
echo "✅ Generated release notes from changelog"
RELEASE_NOTES="$CHANGELOG_HTML"
fi
if [[ "$RELEASE_TYPE" == "stable" ]]; then
gh release create "$TAG_NAME" \
--title "VibeTunnel $RELEASE_VERSION" \
--notes "$RELEASE_NOTES" \
"$DMG_PATH"
else
gh release create "$TAG_NAME" \
--title "VibeTunnel $RELEASE_VERSION" \
--notes "$RELEASE_NOTES" \
--prerelease \
"$DMG_PATH"
fi
echo -e "${GREEN}✅ GitHub release created${NC}"
# Step 7: Update appcast
echo ""
echo -e "${BLUE}📋 Step 7/7: Updating appcast...${NC}"
# Generate appcast
echo "🔐 Generating appcast with EdDSA signatures..."
"$SCRIPT_DIR/generate-appcast.sh"
# Verify the appcast was updated
if [[ "$RELEASE_TYPE" == "stable" ]]; then
if ! grep -q "<sparkle:version>$BUILD_NUMBER</sparkle:version>" "$PROJECT_ROOT/appcast.xml"; then
echo -e "${YELLOW}⚠️ Appcast may not have been updated. Please check manually.${NC}"
fi
else
if ! grep -q "<sparkle:version>$BUILD_NUMBER</sparkle:version>" "$PROJECT_ROOT/appcast-prerelease.xml"; then
echo -e "${YELLOW}⚠️ Pre-release appcast may not have been updated. Please check manually.${NC}"
fi
fi
echo -e "${GREEN}✅ Appcast updated${NC}"
# Commit and push appcast files
echo ""
echo "📤 Committing and pushing appcast..."
git add "$PROJECT_ROOT/appcast.xml" "$PROJECT_ROOT/appcast-prerelease.xml" 2>/dev/null || true
if ! git diff --cached --quiet; then
git commit -m "Update appcast for $RELEASE_VERSION"
git push origin main
echo -e "${GREEN}✅ Appcast changes pushed${NC}"
else
echo " No appcast changes to commit"
fi
# Optional: Verify appcast
echo ""
echo "🔍 Verifying appcast files..."
if "$SCRIPT_DIR/verify-appcast.sh" | grep -q "All appcast checks passed"; then
echo -e "${GREEN}✅ Appcast verification passed${NC}"
else
echo -e "${YELLOW}⚠️ Some appcast issues detected. Please review the output above.${NC}"
fi
echo ""
echo -e "${GREEN}🎉 Release Complete!${NC}"
echo "=================="
echo ""
echo -e "${GREEN}✅ Successfully released VibeTunnel $RELEASE_VERSION${NC}"
echo ""
echo "Release details:"
echo " - Version: $RELEASE_VERSION"
echo " - Build: $BUILD_NUMBER"
echo " - Tag: $TAG_NAME"
echo " - DMG: $DMG_NAME"
echo " - GitHub: https://github.com/amantus-ai/vibetunnel/releases/tag/$TAG_NAME"
echo ""
if [[ "$RELEASE_TYPE" != "stable" ]]; then
echo "📝 Note: This is a pre-release. Users with 'Include Pre-releases' enabled will receive this update."
else
echo "📝 Note: This is a stable release. All users will receive this update."
fi
echo ""
echo "💡 Next steps:"
echo " - Test the update from an older version"
echo " - Monitor Console.app for any update errors"
echo " - Update release notes on GitHub if needed"

350
scripts/sign-and-notarize.sh Executable file
View file

@ -0,0 +1,350 @@
#!/bin/bash
# sign-and-notarize.sh - Comprehensive code signing and notarization script for VibeTunnel
#
# This script handles the full process of:
# 1. Code signing with hardened runtime
# 2. Notarization with Apple
# 3. Stapling the notarization ticket
# 4. Creating distributable ZIP archives
set -euo pipefail
# Get script directory and ensure we're in the right location
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" &> /dev/null && pwd)"
APP_DIR="$(cd "$SCRIPT_DIR/.." &> /dev/null && pwd)"
cd "$APP_DIR" || { echo "Error: Failed to change directory to $APP_DIR"; exit 1; }
# Initialize variables with defaults
BUNDLE_DIR="build/Build/Products/Release/VibeTunnel.app"
APP_BUNDLE_PATH="$APP_DIR/$BUNDLE_DIR"
ZIP_PATH="$APP_DIR/build/VibeTunnel-notarize.zip"
FINAL_ZIP_PATH="$APP_DIR/build/VibeTunnel-notarized.zip"
MAX_RETRIES=3
RETRY_DELAY=30
TIMEOUT_MINUTES=30
# Operation flags - what parts of the process to run
DO_SIGNING=true
DO_NOTARIZATION=false
CREATE_ZIP=true
SKIP_STAPLE=false
VERBOSE=false
# Log helper function with timestamp
log() {
echo "[$(date "+%Y-%m-%d %H:%M:%S")] $1"
}
# Error logging
error() {
log "❌ ERROR: $1"
return 1
}
# Success logging
success() {
log "$1"
}
# Print usage information
print_usage() {
echo "Sign and Notarize Script for VibeTunnel Mac App"
echo ""
echo "Usage: $0 [options]"
echo ""
echo "Authentication Options (required for notarization):"
echo " --api-key-p8 KEY App Store Connect API key content (.p8)"
echo " --api-key-id ID App Store Connect API Key ID"
echo " --api-key-issuer ID App Store Connect API Key Issuer ID"
echo ""
echo "Process Control Options:"
echo " --sign-only Only perform code signing, skip notarization"
echo " --notarize-only Skip signing and only perform notarization"
echo " --sign-and-notarize Perform both signing and notarization (default if credentials provided)"
echo ""
echo "General Options:"
echo " --app-path PATH Path to the app bundle (default: $BUNDLE_DIR)"
echo " --identity ID Developer ID certificate to use for signing"
echo " --skip-staple Skip stapling the notarization ticket to the app"
echo " --no-zip Skip creating distributable ZIP archive"
echo " --timeout MINUTES Notarization timeout in minutes (default: 30)"
echo " --verbose Enable verbose output"
echo " --help Show this help message"
}
# Function to read credentials from environment and arguments
read_credentials() {
# Initialize with existing environment variables
local api_key_p8="${APP_STORE_CONNECT_API_KEY_P8:-}"
local api_key_id="${APP_STORE_CONNECT_KEY_ID:-}"
local api_key_issuer="${APP_STORE_CONNECT_ISSUER_ID:-}"
local sign_identity="${SIGN_IDENTITY:-Developer ID Application}"
# Save original arguments for explicit flag detection
local original_args=("$@")
# Parse command line arguments
while [[ $# -gt 0 ]]; do
case "$1" in
# Authentication options
--api-key-p8)
api_key_p8="$2"
shift 2
;;
--api-key-id)
api_key_id="$2"
shift 2
;;
--api-key-issuer)
api_key_issuer="$2"
shift 2
;;
--identity)
sign_identity="$2"
shift 2
;;
# Process control options
--sign-only)
DO_SIGNING=true
DO_NOTARIZATION=false
shift
;;
--notarize-only)
DO_SIGNING=false
DO_NOTARIZATION=true
shift
;;
--sign-and-notarize)
DO_SIGNING=true
DO_NOTARIZATION=true
shift
;;
# General options
--app-path)
APP_BUNDLE_PATH="$2"
BUNDLE_DIR="$(basename "$APP_BUNDLE_PATH")"
shift 2
;;
--skip-staple)
SKIP_STAPLE=true
shift
;;
--no-zip)
CREATE_ZIP=false
shift
;;
--timeout)
TIMEOUT_MINUTES="$2"
shift 2
;;
--verbose)
VERBOSE=true
shift
;;
--help)
print_usage
exit 0
;;
*)
shift
;;
esac
done
# Export as environment variables
export APP_STORE_CONNECT_API_KEY_P8="$api_key_p8"
export APP_STORE_CONNECT_KEY_ID="$api_key_id"
export APP_STORE_CONNECT_ISSUER_ID="$api_key_issuer"
export SIGN_IDENTITY="$sign_identity"
# If notarization credentials are available and no explicit flags were set, enable notarization
if [ -n "$api_key_p8" ] && [ -n "$api_key_id" ] && [ -n "$api_key_issuer" ]; then
# Only auto-enable notarization if no explicit process control flag was provided
local explicit_flag_provided=false
for arg in "${original_args[@]}"; do
case "$arg" in
--sign-only|--notarize-only|--sign-and-notarize)
explicit_flag_provided=true
break
;;
esac
done
if [ "$explicit_flag_provided" = false ] && [ "$DO_NOTARIZATION" = false ] && [ "$DO_SIGNING" = true ]; then
DO_NOTARIZATION=true
log "Notarization credentials detected. Will perform both signing and notarization."
fi
fi
}
# Retry function for operations that might fail
retry_operation() {
local cmd="$1"
local desc="$2"
local attempt=1
local result
while [ $attempt -le $MAX_RETRIES ]; do
log "Attempt $attempt/$MAX_RETRIES: $desc"
if result=$(eval "$cmd" 2>&1); then
echo "$result"
return 0
else
local exit_code=$?
log "Attempt $attempt failed (exit code: $exit_code)"
if [ "$VERBOSE" = "true" ]; then
log "Command output: $result"
fi
if [ $attempt -lt $MAX_RETRIES ]; then
log "Retrying in $RETRY_DELAY seconds..."
sleep $RETRY_DELAY
fi
fi
attempt=$((attempt + 1))
done
error "Failed after $MAX_RETRIES attempts: $desc"
echo "$result"
return 1
}
# Function to perform code signing
perform_signing() {
log "Starting code signing process for VibeTunnel..."
# Check if the app bundle exists
if [ ! -d "$APP_BUNDLE_PATH" ]; then
error "App bundle not found at $APP_BUNDLE_PATH"
log "Please build the app first by running ./scripts/build.sh"
exit 1
fi
log "Found app bundle at $APP_BUNDLE_PATH"
# Call the codesign script
if ! "$SCRIPT_DIR/codesign-app.sh" "$APP_BUNDLE_PATH" "$SIGN_IDENTITY"; then
error "Code signing failed"
exit 1
fi
success "Code signing completed successfully!"
}
# Function to perform app notarization
perform_notarization() {
log "Starting notarization process for VibeTunnel..."
# Check for authentication requirements
MISSING_VARS=()
[ -z "${APP_STORE_CONNECT_API_KEY_P8:-}" ] && MISSING_VARS+=("APP_STORE_CONNECT_API_KEY_P8")
[ -z "${APP_STORE_CONNECT_KEY_ID:-}" ] && MISSING_VARS+=("APP_STORE_CONNECT_KEY_ID")
[ -z "${APP_STORE_CONNECT_ISSUER_ID:-}" ] && MISSING_VARS+=("APP_STORE_CONNECT_ISSUER_ID")
if [ ${#MISSING_VARS[@]} -gt 0 ]; then
error "Missing required variables for notarization: ${MISSING_VARS[*]}"
log "Please provide --api-key-p8, --api-key-id, and --api-key-issuer options"
log "or set the corresponding environment variables."
exit 1
fi
# Ensure app is signed if needed
if [ "$DO_SIGNING" = true ] || ! codesign --verify --verbose=1 "$APP_BUNDLE_PATH" &>/dev/null; then
log "Signing needs to be performed before notarization..."
perform_signing
else
log "App already properly signed, skipping signing step"
fi
# Call the notarization script
if ! "$SCRIPT_DIR/notarize-app.sh" "$APP_BUNDLE_PATH"; then
error "Notarization failed"
exit 1
fi
success "Notarization completed successfully!"
# Create distributable ZIP archive if needed
if [ "$CREATE_ZIP" = true ]; then
log "Creating distributable ZIP archive..."
rm -f "$FINAL_ZIP_PATH" # Remove existing zip if any
mkdir -p "$(dirname "$FINAL_ZIP_PATH")"
if ! ditto -c -k --keepParent "$APP_BUNDLE_PATH" "$FINAL_ZIP_PATH"; then
error "Failed to create ZIP archive"
else
success "Distributable ZIP archive created: $FINAL_ZIP_PATH"
# Calculate file size and hash for verification
ZIP_SIZE=$(du -h "$FINAL_ZIP_PATH" | cut -f1)
ZIP_SHA=$(shasum -a 256 "$FINAL_ZIP_PATH" | cut -d' ' -f1)
log "ZIP archive size: $ZIP_SIZE"
log "ZIP SHA-256 hash: $ZIP_SHA"
fi
fi
}
# Main execution starts here
log "Starting sign and notarize script for VibeTunnel..."
# Read credentials from all possible sources
read_credentials "$@"
# Check if the app bundle exists
if [ ! -d "$APP_BUNDLE_PATH" ]; then
error "App bundle not found at $APP_BUNDLE_PATH"
log "Please build the app first by running ./scripts/build.sh"
exit 1
fi
log "Found app bundle at $APP_BUNDLE_PATH"
# Check if we should do code signing
if [ "$DO_SIGNING" = true ]; then
perform_signing
else
log "Skipping code signing as requested"
fi
# Check if we should do notarization
if [ "$DO_NOTARIZATION" = true ]; then
perform_notarization
else
log "Skipping notarization as requested"
# Create a simple ZIP file if signing only and zip creation is requested
if [ "$DO_SIGNING" = true ] && [ "$CREATE_ZIP" = true ]; then
log "Creating distributable ZIP archive after signing..."
mkdir -p "$(dirname "$FINAL_ZIP_PATH")"
if ! ditto -c -k --keepParent "$APP_BUNDLE_PATH" "$FINAL_ZIP_PATH"; then
error "Failed to create ZIP archive"
else
success "Distributable ZIP archive created: $FINAL_ZIP_PATH"
ZIP_SIZE=$(du -h "$FINAL_ZIP_PATH" | cut -f1)
ZIP_SHA=$(shasum -a 256 "$FINAL_ZIP_PATH" | cut -d' ' -f1)
log "ZIP archive size: $ZIP_SIZE"
log "ZIP SHA-256 hash: $ZIP_SHA"
fi
fi
fi
# Print final status summary
log ""
log "Operation summary:"
log "✅ App bundle: $APP_BUNDLE_PATH"
if [ "$DO_SIGNING" = true ]; then
log "✅ Code signing: Completed"
fi
if [ "$DO_NOTARIZATION" = true ]; then
log "✅ Notarization: Completed"
if [ "$SKIP_STAPLE" = false ]; then
log "✅ Stapling: Completed (users can run without security warnings)"
else
log "⚠️ Stapling: Skipped"
fi
fi
if [ "$CREATE_ZIP" = true ] && [ -f "$FINAL_ZIP_PATH" ]; then
log "✅ Distributable ZIP archive: $FINAL_ZIP_PATH"
fi
success "Script completed successfully!"

298
scripts/verify-app.sh Executable file
View file

@ -0,0 +1,298 @@
#!/bin/bash
# App Verification Script for VibeTunnel
# Comprehensive verification of built app, DMG, entitlements, and notarization
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
# Color codes for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
# Usage
if [[ $# -lt 1 ]]; then
echo "Usage: $0 <app-path-or-dmg>"
echo ""
echo "Verifies app bundle or DMG for:"
echo " - Code signing"
echo " - Notarization"
echo " - Entitlements"
echo " - Sparkle XPC services"
echo " - Build numbers"
exit 1
fi
TARGET="$1"
TEMP_MOUNT=""
APP_PATH=""
# Function to cleanup
cleanup() {
if [[ -n "$TEMP_MOUNT" ]] && [[ -d "$TEMP_MOUNT" ]]; then
echo "🧹 Cleaning up..."
hdiutil detach "$TEMP_MOUNT" -quiet 2>/dev/null || true
fi
}
trap cleanup EXIT
echo "🔍 VibeTunnel App Verification"
echo "=============================="
echo ""
# Handle DMG or App bundle
if [[ "$TARGET" == *.dmg ]]; then
echo "📀 Mounting DMG: $TARGET"
TEMP_MOUNT=$(hdiutil attach "$TARGET" -quiet -nobrowse | grep -E '^\s*/Volumes/' | tail -1 | awk '{print $NF}')
APP_PATH="$TEMP_MOUNT/VibeTunnel.app"
if [[ ! -d "$APP_PATH" ]]; then
echo -e "${RED}❌ VibeTunnel.app not found in DMG${NC}"
exit 1
fi
else
APP_PATH="$TARGET"
fi
if [[ ! -d "$APP_PATH" ]]; then
echo -e "${RED}❌ App bundle not found at: $APP_PATH${NC}"
exit 1
fi
echo "📱 Checking app: $APP_PATH"
echo ""
# 1. Basic Info
echo "📌 Basic Information:"
BUNDLE_ID=$(defaults read "$APP_PATH/Contents/Info.plist" CFBundleIdentifier 2>/dev/null || echo "unknown")
VERSION=$(defaults read "$APP_PATH/Contents/Info.plist" CFBundleShortVersionString 2>/dev/null || echo "unknown")
BUILD=$(defaults read "$APP_PATH/Contents/Info.plist" CFBundleVersion 2>/dev/null || echo "unknown")
echo " Bundle ID: $BUNDLE_ID"
echo " Version: $VERSION"
echo " Build: $BUILD"
echo ""
# 2. Code Signing
echo "📌 Code Signing:"
if codesign -dv "$APP_PATH" 2>&1 | grep -q "Signature=adhoc"; then
echo -e "${YELLOW} ⚠️ App is ad-hoc signed (development)${NC}"
elif codesign -dv "$APP_PATH" 2>&1 | grep -q "Authority=Developer ID Application"; then
echo -e "${GREEN} ✅ App is signed with Developer ID${NC}"
SIGNING_ID=$(codesign -dv "$APP_PATH" 2>&1 | grep "Authority=Developer ID Application" | head -1 | cut -d: -f2- | xargs)
echo " Certificate: $SIGNING_ID"
else
echo -e "${RED} ❌ App signing status unknown${NC}"
fi
# Verify signature
if codesign --verify --deep --strict "$APP_PATH" 2>/dev/null; then
echo -e "${GREEN} ✅ Code signature is valid${NC}"
else
echo -e "${RED} ❌ Code signature verification failed${NC}"
codesign --verify --deep --strict "$APP_PATH" 2>&1 | grep -v "^$" | sed 's/^/ /'
fi
echo ""
# 3. Notarization
echo "📌 Notarization Status:"
if spctl --assess --type execute "$APP_PATH" 2>&1 | grep -q "accepted"; then
echo -e "${GREEN} ✅ App is notarized and accepted by Gatekeeper${NC}"
else
SPCTL_OUTPUT=$(spctl --assess --type execute "$APP_PATH" 2>&1)
if echo "$SPCTL_OUTPUT" | grep -q "rejected"; then
echo -e "${RED} ❌ App is rejected by Gatekeeper${NC}"
echo " $SPCTL_OUTPUT"
else
echo -e "${YELLOW} ⚠️ Notarization status unclear${NC}"
echo " $SPCTL_OUTPUT"
fi
fi
# Check notarization ticket
if codesign -dv "$APP_PATH" 2>&1 | grep -q "Notarization Ticket="; then
echo -e "${GREEN} ✅ Notarization ticket is stapled${NC}"
else
echo -e "${YELLOW} ⚠️ No notarization ticket found (may still be notarized)${NC}"
fi
echo ""
# 4. Entitlements
echo "📌 Entitlements:"
ENTITLEMENTS=$(codesign -d --entitlements :- "$APP_PATH" 2>/dev/null | plutil -p - 2>/dev/null || echo "Failed to extract")
# Check specific entitlements
if echo "$ENTITLEMENTS" | grep -q '"com.apple.security.app-sandbox" => 1'; then
echo -e "${GREEN} ✅ App sandbox is ENABLED${NC}"
else
echo -e "${YELLOW} ⚠️ App sandbox is DISABLED${NC}"
fi
if echo "$ENTITLEMENTS" | grep -q '"com.apple.security.network.client" => 1'; then
echo -e "${GREEN} ✅ Network client access enabled${NC}"
else
echo -e "${RED} ❌ Network client access not enabled${NC}"
fi
if echo "$ENTITLEMENTS" | grep -q '"com.apple.security.files.downloads.read-write" => 1'; then
echo -e "${GREEN} ✅ Downloads folder access enabled${NC}"
else
echo -e "${YELLOW} ⚠️ Downloads folder access not enabled${NC}"
fi
# Show all entitlements
echo " All entitlements:"
echo "$ENTITLEMENTS" | grep -E "=>" | sed 's/^/ /' || echo " None found"
echo ""
# 5. Sparkle Framework and XPC Services
echo "📌 Sparkle Framework:"
SPARKLE_PATH="$APP_PATH/Contents/Frameworks/Sparkle.framework"
if [[ -d "$SPARKLE_PATH" ]]; then
echo -e "${GREEN} ✅ Sparkle framework found${NC}"
# Check XPC services
echo " XPC Services:"
for XPC in "$SPARKLE_PATH/Versions/B/XPCServices"/*.xpc; do
if [[ -d "$XPC" ]]; then
XPC_NAME=$(basename "$XPC")
echo " Checking $XPC_NAME..."
# Check if signed
if codesign --verify "$XPC" 2>/dev/null; then
echo -e "${GREEN}$XPC_NAME is signed${NC}"
else
echo -e "${RED}$XPC_NAME signature invalid${NC}"
fi
# Check entitlements
XPC_ENTITLEMENTS=$(codesign -d --entitlements :- "$XPC" 2>/dev/null | plutil -p - 2>/dev/null || echo "")
if echo "$XPC_ENTITLEMENTS" | grep -q '"com.apple.security.network.client" => 1'; then
echo -e "${GREEN} ✅ Network access enabled for $XPC_NAME${NC}"
else
echo -e "${RED} ❌ Network access NOT enabled for $XPC_NAME${NC}"
fi
fi
done
else
echo -e "${RED} ❌ Sparkle framework not found${NC}"
fi
echo ""
# 6. Build Number Validation Against Appcast
echo "📌 Appcast Validation:"
if [[ -f "$PROJECT_ROOT/appcast.xml" ]] || [[ -f "$PROJECT_ROOT/appcast-prerelease.xml" ]]; then
EXISTING_BUILDS=""
if [[ -f "$PROJECT_ROOT/appcast.xml" ]]; then
EXISTING_BUILDS+=$(grep -E '<sparkle:version>[0-9]+</sparkle:version>' "$PROJECT_ROOT/appcast.xml" 2>/dev/null | sed 's/.*<sparkle:version>\([0-9]*\)<\/sparkle:version>.*/\1/' | tr '\n' ' ')
fi
if [[ -f "$PROJECT_ROOT/appcast-prerelease.xml" ]]; then
EXISTING_BUILDS+=$(grep -E '<sparkle:version>[0-9]+</sparkle:version>' "$PROJECT_ROOT/appcast-prerelease.xml" 2>/dev/null | sed 's/.*<sparkle:version>\([0-9]*\)<\/sparkle:version>.*/\1/' | tr '\n' ' ')
fi
# Check for duplicate
BUILD_FOUND=false
for EXISTING in $EXISTING_BUILDS; do
if [[ "$BUILD" == "$EXISTING" ]]; then
BUILD_FOUND=true
break
fi
done
if [[ "$BUILD_FOUND" == "true" ]]; then
echo -e "${YELLOW} ⚠️ Build $BUILD already exists in appcast${NC}"
else
echo -e "${GREEN} ✅ Build $BUILD is unique${NC}"
fi
# Find highest build
HIGHEST=0
for EXISTING in $EXISTING_BUILDS; do
if [[ "$EXISTING" -gt "$HIGHEST" ]]; then
HIGHEST=$EXISTING
fi
done
if [[ "$BUILD" -gt "$HIGHEST" ]]; then
echo -e "${GREEN} ✅ Build $BUILD is higher than existing ($HIGHEST)${NC}"
else
echo -e "${RED} ❌ Build $BUILD is not higher than existing ($HIGHEST)${NC}"
fi
else
echo " No appcast files found for validation"
fi
echo ""
# 7. Sparkle Public Key
echo "📌 Sparkle Configuration:"
PUBLIC_KEY=$(defaults read "$APP_PATH/Contents/Info.plist" SUPublicEDKey 2>/dev/null || echo "")
if [[ -n "$PUBLIC_KEY" ]]; then
echo -e "${GREEN} ✅ Sparkle public key configured${NC}"
echo " Key: ${PUBLIC_KEY:0:20}..."
else
echo -e "${RED} ❌ No Sparkle public key found${NC}"
fi
FEED_URL=$(defaults read "$APP_PATH/Contents/Info.plist" SUFeedURL 2>/dev/null || echo "")
if [[ -n "$FEED_URL" ]]; then
echo " Feed URL: $FEED_URL"
fi
echo ""
# 8. Summary
echo "📊 Verification Summary:"
echo "========================"
ISSUES=0
# Check critical items
if ! codesign --verify --deep --strict "$APP_PATH" 2>/dev/null; then
echo -e "${RED}❌ Code signature invalid${NC}"
((ISSUES++))
fi
if ! spctl --assess --type execute "$APP_PATH" 2>&1 | grep -q "accepted"; then
echo -e "${RED}❌ Not accepted by Gatekeeper${NC}"
((ISSUES++))
fi
if ! echo "$ENTITLEMENTS" | grep -q '"com.apple.security.network.client" => 1'; then
echo -e "${RED}❌ Missing network entitlement${NC}"
((ISSUES++))
fi
# Check XPC services have network access
if [[ -d "$SPARKLE_PATH/Versions/B/XPCServices/Downloader.xpc" ]]; then
XPC_NET=$(codesign -d --entitlements :- "$SPARKLE_PATH/Versions/B/XPCServices/Downloader.xpc" 2>/dev/null | plutil -p - 2>/dev/null || echo "")
if ! echo "$XPC_NET" | grep -q '"com.apple.security.network.client" => 1'; then
echo -e "${RED}❌ Sparkle Downloader.xpc missing network entitlement${NC}"
((ISSUES++))
fi
fi
if [[ $ISSUES -eq 0 ]]; then
echo -e "${GREEN}✅ All critical checks passed!${NC}"
echo ""
echo "This app is ready for distribution."
else
echo -e "${RED}❌ Found $ISSUES critical issues${NC}"
echo ""
echo "Please fix these issues before releasing."
fi
# Additional info
echo ""
echo "📝 Additional Commands:"
echo " View all entitlements:"
echo " codesign -d --entitlements :- \"$APP_PATH\""
echo ""
echo " Check notarization log:"
echo " xcrun notarytool log <submission-id> --apple-id <your-apple-id>"
echo ""
echo " Verify with spctl:"
echo " spctl -a -vvv \"$APP_PATH\""

198
scripts/verify-appcast.sh Executable file
View file

@ -0,0 +1,198 @@
#!/bin/bash
# Appcast Verification Script for VibeTunnel
# Validates appcast XML files for common issues
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
# Color codes
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
echo "🔍 VibeTunnel Appcast Verification"
echo "================================="
echo ""
ISSUES=0
# Function to validate an appcast file
validate_appcast() {
local APPCAST_FILE="$1"
local APPCAST_NAME="$2"
if [[ ! -f "$APPCAST_FILE" ]]; then
echo -e "${YELLOW}⚠️ $APPCAST_NAME not found${NC}"
return
fi
echo "📌 Checking $APPCAST_NAME:"
# Check if valid XML
if xmllint --noout "$APPCAST_FILE" 2>/dev/null; then
echo -e "${GREEN} ✅ Valid XML syntax${NC}"
else
echo -e "${RED} ❌ Invalid XML syntax${NC}"
xmllint --noout "$APPCAST_FILE" 2>&1 | sed 's/^/ /'
((ISSUES++))
return
fi
# Count items
ITEM_COUNT=$(grep -c "<item>" "$APPCAST_FILE" 2>/dev/null || true)
ITEM_COUNT=${ITEM_COUNT:-0}
echo " Found $ITEM_COUNT release(s)"
if [[ $ITEM_COUNT -eq 0 ]]; then
echo -e "${YELLOW} ⚠️ No releases found in appcast${NC}"
return
fi
# Extract build numbers and versions
BUILDS=($(grep -o '<sparkle:version>[0-9]*</sparkle:version>' "$APPCAST_FILE" | sed 's/<[^>]*>//g'))
VERSIONS=($(grep -o '<sparkle:shortVersionString>[^<]*</sparkle:shortVersionString>' "$APPCAST_FILE" | sed 's/<[^>]*>//g'))
URLS=($(grep -o 'url="[^"]*"' "$APPCAST_FILE" | sed 's/url="//;s/"//'))
SIGNATURES=($(grep -o 'sparkle:edSignature="[^"]*"' "$APPCAST_FILE" | sed 's/sparkle:edSignature="//;s/"//'))
echo ""
for i in "${!BUILDS[@]}"; do
echo " Release #$((i+1)):"
echo " Version: ${VERSIONS[$i]:-<missing>}"
echo " Build: ${BUILDS[$i]:-<missing>}"
# Validate build number
if [[ -z "${BUILDS[$i]:-}" ]]; then
echo -e "${RED} ❌ Missing build number${NC}"
((ISSUES++))
elif ! [[ "${BUILDS[$i]}" =~ ^[0-9]+$ ]]; then
echo -e "${RED} ❌ Invalid build number: ${BUILDS[$i]}${NC}"
((ISSUES++))
else
echo -e "${GREEN} ✅ Valid build number${NC}"
fi
# Validate URL
if [[ -z "${URLS[$i]:-}" ]]; then
echo -e "${RED} ❌ Missing download URL${NC}"
((ISSUES++))
elif [[ "${URLS[$i]}" =~ ^https://github.com/amantus-ai/vibetunnel/releases/download/ ]]; then
echo -e "${GREEN} ✅ Valid GitHub release URL${NC}"
# Check if release exists on GitHub
RELEASE_TAG=$(echo "${URLS[$i]}" | sed -n 's|.*/download/\([^/]*\)/.*|\1|p')
if gh release view "$RELEASE_TAG" &>/dev/null; then
echo -e "${GREEN} ✅ GitHub release exists${NC}"
else
echo -e "${RED} ❌ GitHub release not found: $RELEASE_TAG${NC}"
((ISSUES++))
fi
else
echo -e "${YELLOW} ⚠️ Non-GitHub URL: ${URLS[$i]}${NC}"
fi
# Validate signature
if [[ -z "${SIGNATURES[$i]:-}" ]]; then
echo -e "${RED} ❌ Missing EdDSA signature${NC}"
((ISSUES++))
else
echo -e "${GREEN} ✅ EdDSA signature present${NC}"
fi
echo ""
done
# Check for duplicate build numbers
if [[ ${#BUILDS[@]} -gt 0 ]]; then
echo " Build Number Analysis:"
UNIQUE_BUILDS=$(printf '%s\n' "${BUILDS[@]}" | sort -u | wc -l)
TOTAL_BUILDS=${#BUILDS[@]}
if [[ $UNIQUE_BUILDS -ne $TOTAL_BUILDS ]]; then
echo -e "${RED} ❌ Duplicate build numbers found!${NC}"
printf '%s\n' "${BUILDS[@]}" | sort | uniq -d | while read -r DUP; do
echo " Duplicate: $DUP"
done
((ISSUES++))
else
echo -e "${GREEN} ✅ All build numbers are unique${NC}"
fi
# Check build number ordering
SORTED_BUILDS=($(printf '%s\n' "${BUILDS[@]}" | sort -nr))
if [[ "${SORTED_BUILDS[*]}" == "${BUILDS[*]}" ]]; then
echo -e "${GREEN} ✅ Build numbers are in descending order (newest first)${NC}"
else
echo -e "${YELLOW} ⚠️ Build numbers are not in descending order${NC}"
echo " Expected order: ${SORTED_BUILDS[*]}"
echo " Current order: ${BUILDS[*]}"
fi
fi
echo ""
}
# Validate both appcast files
validate_appcast "$PROJECT_ROOT/appcast.xml" "Stable appcast"
echo ""
validate_appcast "$PROJECT_ROOT/appcast-prerelease.xml" "Pre-release appcast"
# Cross-validation between appcasts
echo ""
echo "📌 Cross-Validation:"
if [[ -f "$PROJECT_ROOT/appcast.xml" ]] && [[ -f "$PROJECT_ROOT/appcast-prerelease.xml" ]]; then
# Get all build numbers from both files
ALL_BUILDS=()
if [[ -f "$PROJECT_ROOT/appcast.xml" ]]; then
while IFS= read -r build; do
ALL_BUILDS+=("$build")
done < <(grep -o '<sparkle:version>[0-9]*</sparkle:version>' "$PROJECT_ROOT/appcast.xml" | sed 's/<[^>]*>//g')
fi
if [[ -f "$PROJECT_ROOT/appcast-prerelease.xml" ]]; then
while IFS= read -r build; do
ALL_BUILDS+=("$build")
done < <(grep -o '<sparkle:version>[0-9]*</sparkle:version>' "$PROJECT_ROOT/appcast-prerelease.xml" | sed 's/<[^>]*>//g')
fi
# Check for duplicates across files
if [[ ${#ALL_BUILDS[@]} -gt 0 ]]; then
UNIQUE_ALL=$(printf '%s\n' "${ALL_BUILDS[@]}" | sort -u | wc -l)
TOTAL_ALL=${#ALL_BUILDS[@]}
if [[ $UNIQUE_ALL -ne $TOTAL_ALL ]]; then
echo -e "${RED} ❌ Build numbers are duplicated between appcast files!${NC}"
printf '%s\n' "${ALL_BUILDS[@]}" | sort | uniq -d | while read -r DUP; do
echo " Duplicate build: $DUP"
done
((ISSUES++))
else
echo -e "${GREEN} ✅ No build number conflicts between appcast files${NC}"
fi
fi
fi
# Summary
echo ""
echo "📊 Appcast Verification Summary:"
echo "================================"
if [[ $ISSUES -eq 0 ]]; then
echo -e "${GREEN}✅ All appcast checks passed!${NC}"
echo ""
echo "Your appcast files are properly formatted."
else
echo -e "${RED}❌ Found $ISSUES issue(s)${NC}"
echo ""
echo "Please fix these issues to ensure proper updates."
fi
# Suggestions
echo ""
echo "💡 Tips:"
echo " - Build numbers must be unique across ALL releases"
echo " - Build numbers should increase monotonically"
echo " - Newest releases should appear first in appcast"
echo " - All releases need EdDSA signatures"
echo " - GitHub releases must exist before appcast update"

295
scripts/version.sh Executable file
View file

@ -0,0 +1,295 @@
#!/bin/bash
# Version Management Script for VibeTunnel
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
# Usage function
usage() {
echo "Usage: $0 [OPTIONS] [VERSION]"
echo ""
echo "Manage VibeTunnel version numbers and prepare releases"
echo ""
echo "OPTIONS:"
echo " --major Bump major version (e.g., 0.1 -> 1.0)"
echo " --minor Bump minor version (e.g., 0.1 -> 0.2)"
echo " --patch Bump patch version (e.g., 0.1.0 -> 0.1.1)"
echo " --prerelease TYPE Create pre-release version (TYPE: alpha, beta, rc)"
echo " --build Bump build number only"
echo " --set VERSION Set specific version (e.g., 1.0.0)"
echo " --current Show current version"
echo " --help Show this help message"
echo ""
echo "EXAMPLES:"
echo " $0 --current # Show current version"
echo " $0 --patch # 0.1 -> 0.1.1"
echo " $0 --minor # 0.1 -> 0.2"
echo " $0 --major # 0.1 -> 1.0"
echo " $0 --prerelease beta # 0.1 -> 0.1-beta.1"
echo " $0 --build # Increment build number"
echo " $0 --set 1.0.0 # Set to specific version"
echo ""
echo "WORKFLOW:"
echo " 1. Use this script to bump version"
echo " 2. Commit the version changes"
echo " 3. Use ./scripts/release-auto.sh to create the release"
echo ""
}
# Get current version from Project.swift
get_current_version() {
grep 'MARKETING_VERSION' "$PROJECT_ROOT/Project.swift" | sed 's/.*"MARKETING_VERSION": "\(.*\)".*/\1/'
}
# Get current build number from Project.swift
get_current_build() {
grep 'CURRENT_PROJECT_VERSION' "$PROJECT_ROOT/Project.swift" | sed 's/.*"CURRENT_PROJECT_VERSION": "\(.*\)".*/\1/'
}
# Update version in Project.swift
update_project_version() {
local new_version="$1"
local new_build="$2"
# Create backup
cp "$PROJECT_ROOT/Project.swift" "$PROJECT_ROOT/Project.swift.bak"
# Update marketing version
sed -i '' "s/\"MARKETING_VERSION\": \".*\"/\"MARKETING_VERSION\": \"$new_version\"/" "$PROJECT_ROOT/Project.swift"
# Update build number
sed -i '' "s/\"CURRENT_PROJECT_VERSION\": \".*\"/\"CURRENT_PROJECT_VERSION\": \"$new_build\"/" "$PROJECT_ROOT/Project.swift"
echo "✅ Updated Project.swift:"
echo " Version: $new_version"
echo " Build: $new_build"
}
# Parse semantic version
parse_version() {
local version="$1"
# For VibeTunnel, handle both X.Y and X.Y.Z formats
if echo "$version" | grep -qE '^[0-9]+\.[0-9]+(\.[0-9]+)?$'; then
echo "$version"
else
echo "❌ Invalid version format: $version"
echo "Expected format: X.Y or X.Y.Z (e.g., 0.1 or 0.1.0)"
exit 1
fi
}
# Increment version component
increment_version() {
local version="$1"
local component="$2" # major, minor, patch
# Handle X.Y format by converting to X.Y.0
if [[ "$version" =~ ^[0-9]+\.[0-9]+$ ]]; then
version="${version}.0"
fi
local major minor patch
IFS='.' read -r major minor patch <<< "$version"
case "$component" in
major)
major=$((major + 1))
minor=0
patch=0
;;
minor)
minor=$((minor + 1))
patch=0
;;
patch)
patch=$((patch + 1))
;;
*)
echo "❌ Invalid component: $component"
exit 1
;;
esac
# Return X.Y format for major/minor versions if patch is 0
if [[ "$patch" == "0" ]] && [[ "$component" != "patch" ]]; then
echo "$major.$minor"
else
echo "$major.$minor.$patch"
fi
}
# Create pre-release version
create_prerelease_version() {
local base_version="$1"
local prerelease_type="$2"
# Validate pre-release type
case "$prerelease_type" in
alpha|beta|rc)
;;
*)
echo "❌ Invalid pre-release type: $prerelease_type"
echo "Valid types: alpha, beta, rc"
exit 1
;;
esac
# Check if base version already has pre-release suffix
if [[ "$base_version" =~ -[a-z]+\.[0-9]+$ ]]; then
# Extract the pre-release number and increment it
local base_part="${base_version%-*}"
local prerelease_part="${base_version##*-}"
local current_type="${prerelease_part%.*}"
local current_number="${prerelease_part##*.}"
if [[ "$current_type" == "$prerelease_type" ]]; then
# Same type, increment number
local new_number=$((current_number + 1))
echo "$base_part-$prerelease_type.$new_number"
else
# Different type, start at 1
echo "$base_part-$prerelease_type.1"
fi
else
# No pre-release suffix, add one
echo "$base_version-$prerelease_type.1"
fi
}
# Main script logic
main() {
local action=""
local prerelease_type=""
local new_version=""
# Parse arguments
while [[ $# -gt 0 ]]; do
case $1 in
--major|--minor|--patch|--build)
action="${1#--}"
shift
;;
--prerelease)
action="prerelease"
if [[ $# -lt 2 ]]; then
echo "❌ --prerelease requires TYPE argument"
usage
exit 1
fi
prerelease_type="$2"
shift 2
;;
--set)
action="set"
if [[ $# -lt 2 ]]; then
echo "❌ --set requires VERSION argument"
usage
exit 1
fi
new_version="$2"
shift 2
;;
--current)
action="current"
shift
;;
--help)
usage
exit 0
;;
*)
echo "❌ Unknown option: $1"
usage
exit 1
;;
esac
done
# Get current version info
local current_version
local current_build
current_version=$(get_current_version)
current_build=$(get_current_build)
echo "🏷️ VibeTunnel Version Management"
echo "📦 Current version: $current_version"
echo "🔢 Current build: $current_build"
echo ""
# Handle actions
case "$action" in
current)
echo "✅ Current version: $current_version (build $current_build)"
exit 0
;;
major|minor|patch)
# Parse current version (remove any pre-release suffix)
local base_version
base_version=$(parse_version "$current_version")
new_version=$(increment_version "$base_version" "$action")
local new_build=$((current_build + 1))
;;
prerelease)
new_version=$(create_prerelease_version "$current_version" "$prerelease_type")
local new_build=$((current_build + 1))
;;
build)
new_version="$current_version"
local new_build=$((current_build + 1))
;;
set)
# Validate the provided version
parse_version "$new_version" > /dev/null
local new_build=$((current_build + 1))
;;
"")
echo "❌ No action specified"
usage
exit 1
;;
*)
echo "❌ Unknown action: $action"
usage
exit 1
;;
esac
# Confirm the change
echo "📝 Proposed changes:"
echo " Version: $current_version -> $new_version"
echo " Build: $current_build -> $new_build"
echo ""
read -p "Continue? (y/N): " -n 1 -r
echo
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
echo "❌ Aborted"
exit 1
fi
# Apply the changes
update_project_version "$new_version" "$new_build"
echo ""
echo "✅ Version updated successfully!"
echo ""
echo "📋 Next steps:"
echo " 1. Review the changes: git diff Project.swift"
echo " 2. Commit the version bump: git add Project.swift && git commit -m \"Bump version to $new_version\""
echo " 3. Create the release: ./scripts/release-auto.sh stable"
if [[ "$new_version" =~ -[a-z]+\.[0-9]+$ ]]; then
echo " 3. Create the pre-release: ./scripts/release-auto.sh ${prerelease_type} ${new_version##*.}"
fi
echo ""
}
# Validate Project.swift exists
if [[ ! -f "$PROJECT_ROOT/Project.swift" ]]; then
echo "❌ Project.swift not found in $PROJECT_ROOT"
exit 1
fi
# Run main function
main "$@"