Add macOS app foundation with release infrastructure (#1)
BIN
.DS_Store
vendored
Normal file
8
.github-config
Normal 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
|
|
@ -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.
|
||||
619
VibeTunnel.xcodeproj/project.pbxproj
Normal 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 */;
|
||||
}
|
||||
7
VibeTunnel.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "self:">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
|
|
@ -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
|
||||
}
|
||||
BIN
VibeTunnel.xcodeproj/project.xcworkspace/xcuserdata/steipete.xcuserdatad/UserInterfaceState.xcuserstate
generated
Normal 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
BIN
VibeTunnel/Assets.xcassets/.DS_Store
vendored
Normal 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
|
||||
}
|
||||
}
|
||||
68
VibeTunnel/Assets.xcassets/AppIcon.appiconset/Contents.json
Normal 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
|
||||
}
|
||||
}
|
||||
BIN
VibeTunnel/Assets.xcassets/AppIcon.appiconset/icon_128x128.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 82 KiB |
BIN
VibeTunnel/Assets.xcassets/AppIcon.appiconset/icon_16x16.png
Normal file
|
After Width: | Height: | Size: 719 B |
BIN
VibeTunnel/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
VibeTunnel/Assets.xcassets/AppIcon.appiconset/icon_256x256.png
Normal file
|
After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 343 KiB |
BIN
VibeTunnel/Assets.xcassets/AppIcon.appiconset/icon_32x32.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
VibeTunnel/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png
Normal file
|
After Width: | Height: | Size: 7 KiB |
BIN
VibeTunnel/Assets.xcassets/AppIcon.appiconset/icon_512x512.png
Normal file
|
After Width: | Height: | Size: 343 KiB |
|
After Width: | Height: | Size: 1.5 MiB |
6
VibeTunnel/Assets.xcassets/Contents.json
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
26
VibeTunnel/Assets.xcassets/menubar.imageset/Contents.json
vendored
Normal 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"
|
||||
}
|
||||
}
|
||||
BIN
VibeTunnel/Assets.xcassets/menubar.imageset/menubar.png
vendored
Normal file
|
After Width: | Height: | Size: 897 B |
BIN
VibeTunnel/Assets.xcassets/menubar.imageset/menubar@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
VibeTunnel/Core/.DS_Store
vendored
Normal file
72
VibeTunnel/Core/Models/TunnelSession.swift
Normal 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]
|
||||
}
|
||||
90
VibeTunnel/Core/Models/UpdateChannel.swift
Normal 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 }
|
||||
}
|
||||
107
VibeTunnel/Core/Services/AuthenticationMiddleware.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
334
VibeTunnel/Core/Services/SparkleUpdaterManager.swift
Normal 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
|
||||
}
|
||||
}
|
||||
41
VibeTunnel/Core/Services/StartupManager.swift
Normal 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
|
||||
}
|
||||
}
|
||||
163
VibeTunnel/Core/Services/TerminalManager.swift
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
271
VibeTunnel/Core/Services/TunnelClient.swift
Normal 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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
259
VibeTunnel/Core/Services/TunnelServer.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
151
VibeTunnel/Core/Services/TunnelServerDemo.swift
Normal 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
|
||||
*/
|
||||
196
VibeTunnel/Core/Services/WebSocketHandler.swift
Normal 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
|
|
@ -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
186
VibeTunnel/Presentation/Utilities/CommonViewModifiers.swift
Normal 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))
|
||||
}
|
||||
59
VibeTunnel/Presentation/Utilities/View+Cursor.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
180
VibeTunnel/Presentation/Views/AboutView.swift
Normal 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)
|
||||
}
|
||||
279
VibeTunnel/SettingsView.swift
Normal 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()
|
||||
}
|
||||
39
VibeTunnel/Utilities/AboutWindow.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
183
VibeTunnel/VibeTunnelApp.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
20
VibeTunnel/app-config.plist
Normal 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>
|
||||
12
VibeTunnel/sparkle-public-ed-key.txt
Normal 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
|
||||
12
VibeTunnel/version.xcconfig
Normal 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
|
||||
17
VibeTunnelTests/VibeTunnelTests.swift
Normal 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.
|
||||
}
|
||||
|
||||
}
|
||||
41
VibeTunnelUITests/VibeTunnelUITests.swift
Normal 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 it’s 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
33
VibeTunnelUITests/VibeTunnelUITestsLaunchTests.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
188
docs/hummingbird-integration.md
Normal 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
|
|
@ -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
51689
docs/uikit.md
Normal file
210
scripts/README.md
Normal 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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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/&/\&/g; s/</\</g; s/>/\>/g; s/"/\"/g; s/'"'"'/\'/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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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 "$@"
|
||||