From 4efa4cfaa95785e0f9efbb1304d4ef13b780f39b Mon Sep 17 00:00:00 2001 From: Biraj Date: Mon, 13 Jul 2026 13:40:38 -0700 Subject: [PATCH 1/2] feat: macos --- .gitignore | 4 +- dev/build.js | 4 +- dev/macos/build.js | 12 + dev/macos/start.js | 9 + dev/start.js | 4 +- dev/version.js | 6 + .../macos/shellular.xcodeproj/project.pbxproj | 376 ++++++++++++++++++ .../xcshareddata/xcschemes/shellular.xcscheme | 87 ++++ platforms/macos/shellular/App.swift | 34 ++ .../AppIcon.appiconset/Contents.json | 1 + .../shellular/Assets.xcassets/Contents.json | 1 + platforms/macos/shellular/Bridge.swift | 38 ++ .../macos/shellular/BrowserService.swift | 44 ++ .../macos/shellular/EmbeddedProxyServer.swift | 327 +++++++++++++++ .../shellular/EmbeddedProxyService.swift | 86 ++++ platforms/macos/shellular/Info.plist | 42 ++ .../macos/shellular/ScannerService.swift | 13 + platforms/macos/shellular/Services.swift | 85 ++++ .../shellular/Shellular.icon/Assets/logo.svg | 27 ++ .../macos/shellular/Shellular.icon/icon.json | 74 ++++ platforms/macos/shellular/SocketService.swift | 277 +++++++++++++ platforms/macos/shellular/WebView.swift | 68 ++++ .../macos/shellular/shellular.entitlements | 4 + src/boot.ts | 2 + src/index.d.ts | 3 +- src/main.ts | 2 +- src/platforms/macos/index.ts | 1 + src/state/connection.ts | 4 +- webpack.config.js | 7 + 29 files changed, 1632 insertions(+), 10 deletions(-) create mode 100644 dev/macos/build.js create mode 100644 dev/macos/start.js create mode 100644 platforms/macos/shellular.xcodeproj/project.pbxproj create mode 100644 platforms/macos/shellular.xcodeproj/xcshareddata/xcschemes/shellular.xcscheme create mode 100644 platforms/macos/shellular/App.swift create mode 100644 platforms/macos/shellular/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 platforms/macos/shellular/Assets.xcassets/Contents.json create mode 100644 platforms/macos/shellular/Bridge.swift create mode 100644 platforms/macos/shellular/BrowserService.swift create mode 100644 platforms/macos/shellular/EmbeddedProxyServer.swift create mode 100644 platforms/macos/shellular/EmbeddedProxyService.swift create mode 100644 platforms/macos/shellular/Info.plist create mode 100644 platforms/macos/shellular/ScannerService.swift create mode 100644 platforms/macos/shellular/Services.swift create mode 100644 platforms/macos/shellular/Shellular.icon/Assets/logo.svg create mode 100644 platforms/macos/shellular/Shellular.icon/icon.json create mode 100644 platforms/macos/shellular/SocketService.swift create mode 100644 platforms/macos/shellular/WebView.swift create mode 100644 platforms/macos/shellular/shellular.entitlements create mode 100644 src/platforms/macos/index.ts diff --git a/.gitignore b/.gitignore index 7cd5a3c..3246736 100644 --- a/.gitignore +++ b/.gitignore @@ -26,9 +26,7 @@ local.properties # Platform-specific files .gradle -platforms/android/app/src/main/assets/bundle -platforms/ios/shellular/bundle -platforms/browser/bundle +platforms/**/*/bundle platforms/android/app/release platforms/android/build keystore.jks diff --git a/dev/build.js b/dev/build.js index 7dc39b0..1283e93 100644 --- a/dev/build.js +++ b/dev/build.js @@ -5,10 +5,10 @@ import config from "./config.js"; config("production"); const args = process.argv.slice(2); -const platform = args.find((arg) => /(android|ios|browser)/i.test(arg)); +const platform = args.find((arg) => /(android|ios|macos|browser)/i.test(arg)); if (!platform) { - console.error("Please specify a platform: android, ios, or browser"); + console.error("Please specify a platform: android, ios, macos, or browser"); process.exit(1); } diff --git a/dev/macos/build.js b/dev/macos/build.js new file mode 100644 index 0000000..fbfcf60 --- /dev/null +++ b/dev/macos/build.js @@ -0,0 +1,12 @@ +import { exec } from "node:child_process"; +import { join } from "node:path"; + +export default async function build() { + const root = join(process.cwd(), "platforms/macos"); + await new Promise((resolve, reject) => { + const child = exec(`xcodebuild archive -project "${join(root, "shellular.xcodeproj")}" -scheme shellular -configuration Release -destination 'generic/platform=macOS' -archivePath "${join(root, "shellular.xcarchive")}"`); + child.stdout?.pipe(process.stdout); child.stderr?.pipe(process.stderr); + child.on("close", code => code === 0 ? resolve() : reject(new Error(`xcodebuild exited ${code}`))); + child.on("error", reject); + }); +} diff --git a/dev/macos/start.js b/dev/macos/start.js new file mode 100644 index 0000000..6c0d8b5 --- /dev/null +++ b/dev/macos/start.js @@ -0,0 +1,9 @@ +import { exec } from "node:child_process"; +import { join } from "node:path"; + +export default async function start(server) { + const project = join(process.cwd(), "platforms/macos/shellular.xcodeproj"); + exec(`open "${project}"`); + console.log("Build and run the shellular scheme (My Mac)."); + if (server) console.log(`Dev server: http://${server.host}:${server.port}`); +} diff --git a/dev/start.js b/dev/start.js index a50fb36..0e74eec 100644 --- a/dev/start.js +++ b/dev/start.js @@ -4,7 +4,7 @@ import config from "./config.js"; import getIp from "./getIp.js"; const args = process.argv.slice(2); -const platform = args.find((arg) => /(android|ios|browser)/i.test(arg)) || "android"; +const platform = args.find((arg) => /(android|ios|macos|browser)/i.test(arg)) || "android"; const isRelease = args.includes("--release") || args.includes("-r") || false; const { default: start } = await import(`./${platform}/start.js`); @@ -64,7 +64,7 @@ async function main() { // For android/ios, wait until the first successful compilation so the // bundle with the current port is on disk before installing the APK. // For browser, any output is fine — the browser will wait for the page. - const needsBundle = platform === "android" || platform === "ios"; + const needsBundle = platform === "android" || platform === "ios" || platform === "macos"; if (needsBundle && !chunk.includes("compiled successfully")) { return; } diff --git a/dev/version.js b/dev/version.js index c42eb02..dd7c4fa 100644 --- a/dev/version.js +++ b/dev/version.js @@ -10,6 +10,7 @@ const INCREMENT = revert ? -1 : 1; const packageJsonFile = resolve(process.cwd(), "package.json"); const manifestXmlFile = resolve(process.cwd(), "platforms/android/app/src/main/AndroidManifest.xml"); const pbxprojFile = resolve(process.cwd(), "platforms/ios/shellular.xcodeproj/project.pbxproj"); +const macPbxprojFile = resolve(process.cwd(), "platforms/macos/shellular.xcodeproj/project.pbxproj"); main(); @@ -17,6 +18,7 @@ async function main() { const packageContent = readFileSync(packageJsonFile, "utf8"); const manifestContent = readFileSync(manifestXmlFile, "utf8"); const pbxprojContent = readFileSync(pbxprojFile, "utf8"); + const macPbxprojContent = readFileSync(macPbxprojFile, "utf8"); const packageJson = JSON.parse(packageContent); if (!version) { @@ -51,6 +53,10 @@ async function main() { newPbxprojContent = newPbxprojContent.replace(/CURRENT_PROJECT_VERSION = \d+;/g, `CURRENT_PROJECT_VERSION = ${versionCode};`); writeFileSync(pbxprojFile, newPbxprojContent, "utf8"); + let newMacPbxprojContent = macPbxprojContent.replace(/MARKETING_VERSION = [\d.]+;/g, `MARKETING_VERSION = ${version};`); + newMacPbxprojContent = newMacPbxprojContent.replace(/CURRENT_PROJECT_VERSION = \d+;/g, `CURRENT_PROJECT_VERSION = ${versionCode};`); + writeFileSync(macPbxprojFile, newMacPbxprojContent, "utf8"); + writeFileSync(packageJsonFile, JSON.stringify({ ...packageJson, version, versionCode }, undefined, 2), "utf8"); console.log(`Version updated to ${version} and versionCode to ${versionCode}`); diff --git a/platforms/macos/shellular.xcodeproj/project.pbxproj b/platforms/macos/shellular.xcodeproj/project.pbxproj new file mode 100644 index 0000000..888c260 --- /dev/null +++ b/platforms/macos/shellular.xcodeproj/project.pbxproj @@ -0,0 +1,376 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 46; + objects = { + +/* Begin PBXBuildFile section */ + 10E5C0A912C874394C00C716 /* Cocoa.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E12AEBDA3A520F39A1AC0748 /* Cocoa.framework */; }; + 121224A730036B7700277573 /* Shellular.icon in Resources */ = {isa = PBXBuildFile; fileRef = 121224A630036B7700277573 /* Shellular.icon */; }; + 15A677D8DB0E07AEA33762BB /* BrowserService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62628C73FF1A69BD00091DA1 /* BrowserService.swift */; }; + 41581F5C82786E6EA18A95AE /* EmbeddedProxyService.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE1137739EE1ED6755359FA /* EmbeddedProxyService.swift */; }; + 41595B5E8684E4075170609C /* EmbeddedProxyServer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63EF8969384F356D88104E1B /* EmbeddedProxyServer.swift */; }; + 46266FB94C919F94022C7994 /* Bridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49BEF7A09915818A58E5A2EF /* Bridge.swift */; }; + 6277848D9BFDE2DD87D7382C /* Services.swift in Sources */ = {isa = PBXBuildFile; fileRef = 691AB78EB353B35D93A6A47B /* Services.swift */; }; + 670246D4AFF02259065FFDCB /* bundle in Resources */ = {isa = PBXBuildFile; fileRef = 67C797A43F6F06EB8F3B1E75 /* bundle */; }; + 8F354EBEFAB4E4D2E5BFC0DB /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 93E2D0EAC882DBBEB887234B /* Assets.xcassets */; }; + 8F54DD3E90571D5CA29A106F /* WebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80EF74D452998AB632E94C07 /* WebView.swift */; }; + ECAC48CB6FFEC2C17B0DFEF3 /* App.swift in Sources */ = {isa = PBXBuildFile; fileRef = EF52D9684360646294953B1F /* App.swift */; }; + EDEC0196FD87CAA3BC2C9622 /* SocketService.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4FBC43C303E1D1048DBEFEA /* SocketService.swift */; }; + F3F28883E3832A8790FA187B /* ScannerService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5851AA5FE9CF461DF6F42203 /* ScannerService.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + 121224A630036B7700277573 /* Shellular.icon */ = {isa = PBXFileReference; lastKnownFileType = folder.iconcomposer.icon; path = Shellular.icon; sourceTree = ""; }; + 417201DCA0520F9135E8F640 /* Shellular.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Shellular.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 49BEF7A09915818A58E5A2EF /* Bridge.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = Bridge.swift; sourceTree = ""; }; + 5851AA5FE9CF461DF6F42203 /* ScannerService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ScannerService.swift; sourceTree = ""; }; + 62628C73FF1A69BD00091DA1 /* BrowserService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BrowserService.swift; sourceTree = ""; }; + 63EF8969384F356D88104E1B /* EmbeddedProxyServer.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = EmbeddedProxyServer.swift; sourceTree = ""; }; + 67C797A43F6F06EB8F3B1E75 /* bundle */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = folder; path = bundle; sourceTree = ""; }; + 691AB78EB353B35D93A6A47B /* Services.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = Services.swift; sourceTree = ""; }; + 80EF74D452998AB632E94C07 /* WebView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WebView.swift; sourceTree = ""; }; + 93E2D0EAC882DBBEB887234B /* Assets.xcassets */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + C4FBC43C303E1D1048DBEFEA /* SocketService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SocketService.swift; sourceTree = ""; }; + E12AEBDA3A520F39A1AC0748 /* Cocoa.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Cocoa.framework; path = Platforms/MacOSX.platform/Developer/SDKs/MacOSX15.0.sdk/System/Library/Frameworks/Cocoa.framework; sourceTree = DEVELOPER_DIR; }; + EF52D9684360646294953B1F /* App.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = App.swift; sourceTree = ""; }; + FDE1137739EE1ED6755359FA /* EmbeddedProxyService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = EmbeddedProxyService.swift; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 3BD16157BE059F1446570F05 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 10E5C0A912C874394C00C716 /* Cocoa.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 2FD02A769EE49BD61D587A38 /* shellular */ = { + isa = PBXGroup; + children = ( + EF52D9684360646294953B1F /* App.swift */, + 49BEF7A09915818A58E5A2EF /* Bridge.swift */, + 62628C73FF1A69BD00091DA1 /* BrowserService.swift */, + 63EF8969384F356D88104E1B /* EmbeddedProxyServer.swift */, + FDE1137739EE1ED6755359FA /* EmbeddedProxyService.swift */, + 5851AA5FE9CF461DF6F42203 /* ScannerService.swift */, + 691AB78EB353B35D93A6A47B /* Services.swift */, + C4FBC43C303E1D1048DBEFEA /* SocketService.swift */, + 80EF74D452998AB632E94C07 /* WebView.swift */, + 93E2D0EAC882DBBEB887234B /* Assets.xcassets */, + 67C797A43F6F06EB8F3B1E75 /* bundle */, + 121224A630036B7700277573 /* Shellular.icon */, + ); + path = shellular; + sourceTree = ""; + }; + 4E974F6B0D30EBC919F8E6C7 /* OS X */ = { + isa = PBXGroup; + children = ( + E12AEBDA3A520F39A1AC0748 /* Cocoa.framework */, + ); + name = "OS X"; + sourceTree = ""; + }; + 559D0075DA66C5BA07895207 /* Products */ = { + isa = PBXGroup; + children = ( + 417201DCA0520F9135E8F640 /* Shellular.app */, + ); + name = Products; + sourceTree = ""; + }; + 9CF271001356AE3033D48062 /* Frameworks */ = { + isa = PBXGroup; + children = ( + 4E974F6B0D30EBC919F8E6C7 /* OS X */, + ); + name = Frameworks; + sourceTree = ""; + }; + A94D678588D606588B79AD35 = { + isa = PBXGroup; + children = ( + 559D0075DA66C5BA07895207 /* Products */, + 9CF271001356AE3033D48062 /* Frameworks */, + 2FD02A769EE49BD61D587A38 /* shellular */, + ); + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 100CA492D0A3F26A48EE5C58 /* shellular */ = { + isa = PBXNativeTarget; + buildConfigurationList = 3863D94A6463E22DA4DE156D /* Build configuration list for PBXNativeTarget "shellular" */; + buildPhases = ( + 8BEDFA7469C0F0DCB607BC5F /* Sources */, + 3BD16157BE059F1446570F05 /* Frameworks */, + 49A37EC1A4A7DBC321F81937 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = shellular; + productName = shellular; + productReference = 417201DCA0520F9135E8F640 /* Shellular.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 42695A02F5FBF1E9F63F4667 /* Project object */ = { + isa = PBXProject; + attributes = { + LastSwiftUpdateCheck = 1600; + LastUpgradeCheck = 1600; + TargetAttributes = { + 100CA492D0A3F26A48EE5C58 = { + DevelopmentTeam = 8UGGZ66W6P; + ProvisioningStyle = Automatic; + }; + }; + }; + buildConfigurationList = B2F2B8A4A365315948300247 /* Build configuration list for PBXProject "shellular" */; + compatibilityVersion = "Xcode 3.2"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = A94D678588D606588B79AD35; + productRefGroup = 559D0075DA66C5BA07895207 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 100CA492D0A3F26A48EE5C58 /* shellular */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 49A37EC1A4A7DBC321F81937 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 8F354EBEFAB4E4D2E5BFC0DB /* Assets.xcassets in Resources */, + 121224A730036B7700277573 /* Shellular.icon in Resources */, + 670246D4AFF02259065FFDCB /* bundle in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 8BEDFA7469C0F0DCB607BC5F /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ECAC48CB6FFEC2C17B0DFEF3 /* App.swift in Sources */, + 46266FB94C919F94022C7994 /* Bridge.swift in Sources */, + 15A677D8DB0E07AEA33762BB /* BrowserService.swift in Sources */, + 41595B5E8684E4075170609C /* EmbeddedProxyServer.swift in Sources */, + 41581F5C82786E6EA18A95AE /* EmbeddedProxyService.swift in Sources */, + F3F28883E3832A8790FA187B /* ScannerService.swift in Sources */, + 6277848D9BFDE2DD87D7382C /* Services.swift in Sources */, + EDEC0196FD87CAA3BC2C9622 /* SocketService.swift in Sources */, + 8F54DD3E90571D5CA29A106F /* WebView.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + 7DBCFDA00EBE90AEF4D2347C /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + 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; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + 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; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 80854A1CAB6CE928251CCBF5 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ARCHS = "$(ARCHS_STANDARD)"; + ASSETCATALOG_COMPILER_APPICON_NAME = Shellular; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = shellular/shellular.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 34; + DEVELOPMENT_TEAM = 8UGGZ66W6P; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = shellular/Info.plist; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/../Frameworks"; + MACOSX_DEPLOYMENT_TARGET = 14.0; + MARKETING_VERSION = 0.0.34; + PRODUCT_BUNDLE_IDENTIFIER = io.foxbiz.shellular; + PRODUCT_NAME = Shellular; + SDKROOT = macosx; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 96FB748E707A4C061F67C919 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + 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"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + 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; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OPTIMIZATION_LEVEL = "-Owholemodule"; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + 9872C2A1D9E003D0F537FAF4 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ARCHS = "$(ARCHS_STANDARD)"; + ASSETCATALOG_COMPILER_APPICON_NAME = Shellular; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = shellular/shellular.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 34; + DEVELOPMENT_TEAM = 8UGGZ66W6P; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = shellular/Info.plist; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/../Frameworks"; + MACOSX_DEPLOYMENT_TARGET = 14.0; + MARKETING_VERSION = 0.0.34; + PRODUCT_BUNDLE_IDENTIFIER = io.foxbiz.shellular; + PRODUCT_NAME = Shellular; + SDKROOT = macosx; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 3863D94A6463E22DA4DE156D /* Build configuration list for PBXNativeTarget "shellular" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 9872C2A1D9E003D0F537FAF4 /* Release */, + 80854A1CAB6CE928251CCBF5 /* Debug */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + B2F2B8A4A365315948300247 /* Build configuration list for PBXProject "shellular" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 7DBCFDA00EBE90AEF4D2347C /* Debug */, + 96FB748E707A4C061F67C919 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 42695A02F5FBF1E9F63F4667 /* Project object */; +} diff --git a/platforms/macos/shellular.xcodeproj/xcshareddata/xcschemes/shellular.xcscheme b/platforms/macos/shellular.xcodeproj/xcshareddata/xcschemes/shellular.xcscheme new file mode 100644 index 0000000..06e8775 --- /dev/null +++ b/platforms/macos/shellular.xcodeproj/xcshareddata/xcschemes/shellular.xcscheme @@ -0,0 +1,87 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/platforms/macos/shellular/App.swift b/platforms/macos/shellular/App.swift new file mode 100644 index 0000000..e131576 --- /dev/null +++ b/platforms/macos/shellular/App.swift @@ -0,0 +1,34 @@ +import SwiftUI +import AppKit +import UserNotifications + +final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDelegate { + static weak var shared: AppDelegate? + var deepLinkHandler: ((URL) -> Void)? + func applicationDidFinishLaunching(_ notification: Notification) { + Self.shared = self + UNUserNotificationCenter.current().delegate = self + } + func application(_ application: NSApplication, open urls: [URL]) { urls.forEach { deepLinkHandler?($0) } } + func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification) async -> UNNotificationPresentationOptions { [.banner, .sound] } + func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse) async { + NotificationService.shared.handleTap(response.notification.request.identifier) + } +} + +@main struct shellularApp: App { + @NSApplicationDelegateAdaptor(AppDelegate.self) var delegate + var body: some Scene { + WindowGroup { WebContainer().frame(minWidth: 760, minHeight: 560) } + .defaultSize(width: 1100, height: 760) + .commands { + CommandGroup(after: .newItem) { Button("Open File…") { NotificationCenter.default.post(name: .openFile, object: nil) }.keyboardShortcut("o") } + CommandGroup(after: .sidebar) { Button("Reload") { NotificationCenter.default.post(name: .reloadWebView, object: nil) }.keyboardShortcut("r") } + } + } +} + +extension Notification.Name { + static let openFile = Notification.Name("shellular.openFile") + static let reloadWebView = Notification.Name("shellular.reloadWebView") +} diff --git a/platforms/macos/shellular/Assets.xcassets/AppIcon.appiconset/Contents.json b/platforms/macos/shellular/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..20a9ca1 --- /dev/null +++ b/platforms/macos/shellular/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1 @@ +{"images":[],"info":{"author":"xcode","version":1}} diff --git a/platforms/macos/shellular/Assets.xcassets/Contents.json b/platforms/macos/shellular/Assets.xcassets/Contents.json new file mode 100644 index 0000000..f223229 --- /dev/null +++ b/platforms/macos/shellular/Assets.xcassets/Contents.json @@ -0,0 +1 @@ +{"info":{"author":"xcode","version":1}} diff --git a/platforms/macos/shellular/Bridge.swift b/platforms/macos/shellular/Bridge.swift new file mode 100644 index 0000000..1ae18f9 --- /dev/null +++ b/platforms/macos/shellular/Bridge.swift @@ -0,0 +1,38 @@ +import AppKit +import WebKit + +protocol ServiceProtocol: AnyObject { func exec(action: String, args: [Any], callback: Callback) } +class BaseService: NSObject, ServiceProtocol { + weak var bridge: Bridge?; required init(bridge: Bridge) { self.bridge = bridge } + var webView: WKWebView? { bridge?.webView }; var viewController: WebViewController? { bridge?.viewController } + func exec(action: String, args: [Any], callback: Callback) { callback.error("Action '\(action)' not implemented") } +} +extension Array { subscript(safe index: Int) -> Element? { indices.contains(index) ? self[index] : nil } } + +final class Callback { + let id: Int; private weak var webView: WKWebView? + init(id: Int, webView: WKWebView?) { self.id = id; self.webView = webView } + func success(_ value: Any? = nil, keep: Bool = false) { send(value, nil, keep, false, 0) } + func successBinary(_ data: Data, keep: Bool = false) { send(String(bytes: data, encoding: .isoLatin1), nil, keep, true, data.count) } + func error(_ message: String) { send(nil, message, false, false, 0) } + private func send(_ success: Any?, _ error: String?, _ keep: Bool, _ binary: Bool, _ length: Int) { + var payload: [String: Any] = ["id":id, "keep":keep, "isBinary":binary, "length":length] + if let error { payload["error"] = error } else { payload["success"] = success ?? NSNull() } + guard let data = try? JSONSerialization.data(withJSONObject: payload), let json = String(data: data, encoding: .utf8) else { return } + DispatchQueue.main.async { [weak webView] in webView?.evaluateJavaScript("window.iOS&&window.iOS.callback(\(json))") } + } +} + +final class Bridge: NSObject, WKScriptMessageHandler { + private(set) weak var webView: WKWebView?; private(set) weak var viewController: WebViewController? + private var services: [String: ServiceProtocol] = [:] + func setup(webView: WKWebView, viewController: WebViewController) { + self.webView = webView; self.viewController = viewController + services = ["Native":NativeService(bridge:self), "FileHandler":FileHandlerService(bridge:self), "Device":DeviceService(bridge:self), "Dialog":DialogService(bridge:self), "Encryption":EncryptionService(bridge:self), "Notification":NotificationService.sharedInstance(bridge:self), "SecureStore":SecureStoreService(bridge:self), "Scanner":ScannerService(bridge:self), "Browser":BrowserService(bridge:self), "EmbeddedProxy":EmbeddedProxyService(bridge:self), "SocketService":SocketService(bridge:self)] + } + func userContentController(_ controller: WKUserContentController, didReceive message: WKScriptMessage) { + guard let body = message.body as? [String:Any], let service = body["service"] as? String, let action = body["action"] as? String, let string = body["args"] as? String, let id = body["id"] as? Int, let target = services[service] else { return } + let args = (try? JSONSerialization.jsonObject(with: Data(string.utf8))) as? [Any] ?? [] + target.exec(action: action, args: args, callback: Callback(id: id, webView: webView)) + } +} diff --git a/platforms/macos/shellular/BrowserService.swift b/platforms/macos/shellular/BrowserService.swift new file mode 100644 index 0000000..cafbde4 --- /dev/null +++ b/platforms/macos/shellular/BrowserService.swift @@ -0,0 +1,44 @@ +import AppKit +import AuthenticationServices +import WebKit + +final class BrowserService: BaseService, ASWebAuthenticationPresentationContextProviding { + private var authSession: ASWebAuthenticationSession? + private var browserWindows: [BrowserWindowController] = [] + override func exec(action:String,args:[Any],callback:Callback){switch action{case"open":open(args,html:false,callback);case"openHTML":open(args,html:true,callback);case"openForAuth":auth(args,callback);default:callback.error("Unknown action: \(action)")}} + private func open(_ args:[Any],html:Bool,_ callback:Callback){DispatchQueue.main.async{let controller=BrowserWindowController();if html{controller.loadHTML(args.first as? String ?? "")}else if let s=args.first as? String,let u=URL(string:s){controller.load(u)}else{return callback.error("Invalid URL")};controller.onClose = { [weak self, weak controller] in if let controller { self?.browserWindows.removeAll { $0 === controller } } };self.browserWindows.append(controller);controller.showWindow(nil);NSApp.activate(ignoringOtherApps:true);callback.success()}} + private func auth(_ args:[Any],_ callback:Callback){ + guard let s=args.first as? String,let url=URL(string:s)else{return callback.error("Invalid URL")} + let scheme=args[safe:1] as? String ?? "shellular" + DispatchQueue.main.async { + self.webView?.evaluateJavaScript("window.dispatchEvent(new CustomEvent('browserwillopen'))") + self.authSession?.cancel() + self.authSession=ASWebAuthenticationSession(url:url,callbackURLScheme:scheme){url,error in + DispatchQueue.main.async { + self.authSession=nil + self.webView?.evaluateJavaScript("window.dispatchEvent(new CustomEvent('browserdidclose'))") + guard let url else { return callback.error(error?.localizedDescription ?? "Authentication cancelled") } + guard url.scheme == scheme, url.host == "auth-callback" else { return callback.error("Authentication returned an invalid callback URL") } + var params:[String:String]=[:] + URLComponents(url:url,resolvingAgainstBaseURL:false)?.queryItems?.forEach { params[$0.name]=$0.value ?? "" } + guard let data=try? JSONSerialization.data(withJSONObject:["url":url.absoluteString,"params":params]),let json=String(data:data,encoding:.utf8) else { return callback.error("Could not decode authentication callback") } + callback.success(json) + } + } + self.authSession?.presentationContextProvider=self + self.authSession?.prefersEphemeralWebBrowserSession=false + if self.authSession?.start() != true { self.authSession=nil; callback.error("Unable to start authentication session") } + } + } + func presentationAnchor(for session:ASWebAuthenticationSession)->ASPresentationAnchor{viewController?.view.window ?? NSApp.keyWindow!} +} + +final class BrowserWindowController:NSWindowController,WKNavigationDelegate,NSWindowDelegate{ + private let web=WKWebView() + var onClose:(()->Void)? + init(){let w=NSWindow(contentRect:NSRect(x:0,y:0,width:1000,height:700),styleMask:[.titled,.closable,.miniaturizable,.resizable],backing:.buffered,defer:false);super.init(window:w);w.title="Shellular Browser";w.contentView=web;w.center();w.delegate=self;web.navigationDelegate=self} + required init?(coder:NSCoder){fatalError()} + func load(_ url:URL){web.load(URLRequest(url:url))} + func loadHTML(_ html:String){web.loadHTMLString(html,baseURL:nil)} + func windowWillClose(_ notification:Notification){onClose?()} +} diff --git a/platforms/macos/shellular/EmbeddedProxyServer.swift b/platforms/macos/shellular/EmbeddedProxyServer.swift new file mode 100644 index 0000000..ebc0010 --- /dev/null +++ b/platforms/macos/shellular/EmbeddedProxyServer.swift @@ -0,0 +1,327 @@ +import Foundation +import Network +import WebKit + +final class EmbeddedProxyServer { + + struct PendingConnection { + let connection: NWConnection + let requestId: String + var useChunked = false + } + + static let shared = EmbeddedProxyServer() + + private let queue = DispatchQueue(label: "shellular.embeddedProxy", qos: .userInitiated) + private var listeners: [Int: NWListener] = [:] + private(set) var activePorts: Set = [] + private var pendingConnections: [String: PendingConnection] = [:] + + func startServer(port: Int, webView: WKWebView) -> Bool { + guard !activePorts.contains(port) else { return true } + + let params = NWParameters.tcp + params.allowLocalEndpointReuse = true + + guard let nwPort = NWEndpoint.Port(rawValue: UInt16(port)) else { + print("[EmbeddedProxy] Invalid port: \(port)") + return false + } + + params.requiredLocalEndpoint = NWEndpoint.hostPort( + host: NWEndpoint.Host("127.0.0.1"), + port: nwPort + ) + + guard let listener = try? NWListener(using: params) else { + print("[EmbeddedProxy] Failed to create listener on port \(port)") + return false + } + + listener.newConnectionHandler = { [weak self] connection in + self?.handleConnection(connection, webView: webView) + } + + listener.stateUpdateHandler = { [weak self] state in + switch state { + case .failed(let error): + print("[EmbeddedProxy] Listener failed on port \(port): \(error)") + self?.activePorts.remove(port) + case .cancelled: + self?.activePorts.remove(port) + default: + break + } + } + + listener.start(queue: queue) + listeners[port] = listener + activePorts.insert(port) + print("[EmbeddedProxy] Server started on 127.0.0.1:\(port)") + return true + } + + func stopServer(port: Int) { + guard let listener = listeners[port] else { return } + listener.cancel() + listeners.removeValue(forKey: port) + activePorts.remove(port) + } + + func stopAll() { + for (_, listener) in listeners { + listener.cancel() + } + listeners.removeAll() + activePorts.removeAll() + } + + func hasServer(port: Int) -> Bool { + return activePorts.contains(port) + } + + func cancelAllPending() { + queue.async { [weak self] in + guard let self else { return } + for (_, pending) in self.pendingConnections { + pending.connection.cancel() + } + self.pendingConnections.removeAll() + } + } + + func sendResponseStart(requestId: String, status: Int, statusText: String, headersJson: String) { + queue.async { [weak self] in + guard let self, var pending = self.pendingConnections[requestId] else { return } + + var headerLines = "HTTP/1.1 \(status) \(statusText)\r\n" + var hasTransferEncoding = false + + if let data = headersJson.data(using: .utf8), + let headers = try? JSONSerialization.jsonObject(with: data) as? [String: String] { + for (key, value) in headers { + let lower = key.lowercased() + if lower == "connection" { continue } + + if lower == "transfer-encoding" { + hasTransferEncoding = true + if value.lowercased().contains("chunked") { + pending.useChunked = true + } + } + + if lower == "set-cookie" { + let cookies = value.components(separatedBy: "\n") + for cookie in cookies { + let trimmed = cookie.trimmingCharacters(in: .whitespaces) + if !trimmed.isEmpty { + headerLines += "\(key): \(trimmed)\r\n" + } + } + } else { + headerLines += "\(key): \(value)\r\n" + } + } + } + + if !hasTransferEncoding { + headerLines += "Connection: close\r\n" + } + + headerLines += "\r\n" + self.pendingConnections[requestId] = pending + + guard let headerData = headerLines.data(using: .utf8) else { return } + pending.connection.send(content: headerData, completion: .contentProcessed({ _ in })) + } + } + + func sendResponseData(requestId: String, chunkBase64: String, index: Int) { + queue.async { [weak self] in + guard let self, let pending = self.pendingConnections[requestId] else { return } + guard var data = Data(base64Encoded: chunkBase64) else { return } + + if pending.useChunked { + let hexSize = String(format: "%X", data.count) + var chunked = Data() + chunked.append(hexSize.data(using: .utf8)!) + chunked.append(Data([0x0D, 0x0A])) // \r\n + chunked.append(data) + chunked.append(Data([0x0D, 0x0A])) // \r\n + data = chunked + } + + pending.connection.send(content: data, completion: .contentProcessed({ _ in })) + } + } + + func sendResponseEnd(requestId: String) { + queue.async { [weak self] in + guard let self, let pending = self.pendingConnections.removeValue(forKey: requestId) else { return } + + if pending.useChunked { + let finalChunk = "0\r\n\r\n".data(using: .utf8)! + pending.connection.send(content: finalChunk, completion: .contentProcessed({ _ in + pending.connection.send(content: nil, contentContext: .finalMessage, isComplete: true, completion: .contentProcessed({ _ in + pending.connection.cancel() + })) + })) + } else { + pending.connection.send(content: nil, contentContext: .finalMessage, isComplete: true, completion: .contentProcessed({ _ in + pending.connection.cancel() + })) + } + } + } + + func sendResponseError(requestId: String, errorMessage: String) { + queue.async { [weak self] in + guard let self, let pending = self.pendingConnections.removeValue(forKey: requestId) else { return } + let body = errorMessage.data(using: .utf8) ?? Data() + let response = "HTTP/1.1 502 Bad Gateway\r\nContent-Type: text/plain\r\nContent-Length: \(body.count)\r\nConnection: close\r\n\r\n" + guard let headerData = response.data(using: .utf8) else { + pending.connection.cancel() + return + } + pending.connection.send(content: headerData + body, completion: .contentProcessed({ _ in + pending.connection.cancel() + })) + } + } + + // MARK: - Connection Handling + + private func handleConnection(_ connection: NWConnection, webView: WKWebView) { + connection.start(queue: queue) + readHTTPRequest(connection, webView: webView, accumulatedData: Data()) + } + + private func readHTTPRequest(_ connection: NWConnection, webView: WKWebView, accumulatedData: Data) { + connection.receive(minimumIncompleteLength: 1, maximumLength: 65536) { [weak self] data, _, isComplete, error in + guard let self else { return } + + if let error = error { + connection.cancel() + return + } + + guard let data = data, !data.isEmpty else { + if isComplete { connection.cancel() } + else { self.readHTTPRequest(connection, webView: webView, accumulatedData: accumulatedData) } + return + } + + var allData = accumulatedData + allData.append(data) + + let request = self.parseHTTPRequest(allData) + guard let req = request else { + if isComplete { + self.sendErrorResponse(connection, status: 400, message: "Bad Request") + } else { + self.readHTTPRequest(connection, webView: webView, accumulatedData: allData) + } + return + } + + let contentLength = req.headers.first(where: { $0.key.lowercased() == "content-length" }) + .flatMap { Int($0.value) } ?? 0 + if contentLength > 0, let body = req.body { + if body.count < contentLength && !isComplete { + self.readHTTPRequest(connection, webView: webView, accumulatedData: allData) + return + } + } + + self.proxyRequest(connection, request: req, webView: webView) + } + } + + private func proxyRequest(_ connection: NWConnection, request: ParsedRequest, webView: WKWebView) { + let requestId = "req_\(UUID().uuidString.replacingOccurrences(of: "-", with: ""))" + + queue.async { [weak self] in + self?.pendingConnections[requestId] = PendingConnection(connection: connection, requestId: requestId) + } + + DispatchQueue.main.async { + let headersDict = Dictionary(uniqueKeysWithValues: request.headers.map { ($0.key, $0.value) }) + let headersJson = (try? JSONSerialization.data(withJSONObject: headersDict)).flatMap { + String(data: $0, encoding: .utf8) + } ?? "{}" + + let bodyStr = request.body.flatMap { String(data: $0, encoding: .utf8) } ?? "" + + let escapedHeaders = self.escapeForJS(headersJson) + let escapedBody = self.escapeForJS(bodyStr) + let escapedUrl = self.escapeForJS(request.url) + let escapedMethod = self.escapeForJS(request.method) + + let js = "window.__shellularProxy&&window.__shellularProxy.httpRequest('\(requestId)','\(escapedMethod)','\(escapedUrl)','\(escapedHeaders)','\(escapedBody)')" + webView.evaluateJavaScript(js) + } + } + + private func escapeForJS(_ str: String) -> String { + return str + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "'", with: "\\'") + .replacingOccurrences(of: "\n", with: "\\n") + .replacingOccurrences(of: "\r", with: "\\r") + } + + // MARK: - Error Response + + private func sendErrorResponse(_ connection: NWConnection, status: Int, message: String) { + let body = message.data(using: .utf8) ?? Data() + let response = "HTTP/1.1 \(status) Error\r\nContent-Type: text/plain\r\nContent-Length: \(body.count)\r\nConnection: close\r\n\r\n" + guard let headerData = response.data(using: .utf8) else { + connection.cancel() + return + } + connection.send(content: headerData + body, completion: .contentProcessed({ _ in + connection.cancel() + })) + } + + // MARK: - HTTP Parsing + + struct ParsedRequest { + let method: String + let url: String + let headers: [(key: String, value: String)] + let body: Data? + } + + private func parseHTTPRequest(_ data: Data) -> ParsedRequest? { + guard let string = String(data: data, encoding: .utf8) ?? String(data: data, encoding: .ascii) else { + return nil + } + + let parts = string.components(separatedBy: "\r\n\r\n") + guard let headerSection = parts.first else { return nil } + + let bodyStr = parts.count > 1 ? parts.dropFirst().joined(separator: "\r\n\r\n") : nil + let body = bodyStr.flatMap { $0.data(using: .utf8) } + + var lines = headerSection.components(separatedBy: "\r\n") + guard let requestLine = lines.first else { return nil } + lines.removeFirst() + + let requestParts = requestLine.components(separatedBy: " ") + guard requestParts.count >= 2 else { return nil } + + let method = requestParts[0].uppercased() + let rawUrl = requestParts[1] + + var headers: [(key: String, value: String)] = [] + for line in lines { + guard let colonIndex = line.firstIndex(of: ":") else { continue } + let key = String(line[.. Bool { + guard let webView = bridge?.webView else { return false } + return server.startServer(port: port, webView: webView) + } + + func cleanupServer(port: Int) { + server.stopServer(port: port) + } + + func cleanupAll() { + server.cancelAllPending() + server.stopAll() + } + + func hasServer(port: Int) -> Bool { + return server.hasServer(port: port) + } +} diff --git a/platforms/macos/shellular/Info.plist b/platforms/macos/shellular/Info.plist new file mode 100644 index 0000000..68d0e63 --- /dev/null +++ b/platforms/macos/shellular/Info.plist @@ -0,0 +1,42 @@ + + + + + CFBundleDisplayName + Shellular + CFBundleIconFile + Shellular + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleURLTypes + + + CFBundleURLName + shellular-deep-link + CFBundleURLSchemes + + foxbiz + shellular + + + + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + LSApplicationCategoryType + public.app-category.developer-tools + NSCameraUsageDescription + Shellular uses the camera to scan connection QR codes. + NSLocalNetworkUsageDescription + Shellular connects to development services on your local network. + NSMicrophoneUsageDescription + Shellular uses the microphone when requested by connected tools. + NSPrincipalClass + NSApplication + + diff --git a/platforms/macos/shellular/ScannerService.swift b/platforms/macos/shellular/ScannerService.swift new file mode 100644 index 0000000..6008741 --- /dev/null +++ b/platforms/macos/shellular/ScannerService.swift @@ -0,0 +1,13 @@ +import AppKit +import AVFoundation +import Vision + +final class ScannerService:BaseService,AVCaptureVideoDataOutputSampleBufferDelegate{ + private var session:AVCaptureSession?;private var layer:AVCaptureVideoPreviewLayer?;private var overlay:NSView?;private var scanCallback:Callback?;private var once=false;private var onShow:Callback?;private var onHide:Callback?;private var isProcessingFrame=false + override func exec(action:String,args:[Any],callback:Callback){switch action{case"show":show(args,callback);case"hide":hide(callback);case"scan":once=(args.first as? Bool)==true;scanCallback=callback;case"update":update(args,callback);case"setTheme":callback.success();case"setOnShowListener":onShow=callback;callback.success(nil,keep:true);case"setOnHideListener":onHide=callback;callback.success(nil,keep:true);default:callback.error("Unknown action")}} + private func show(_ args:[Any],_ callback:Callback){let begin={DispatchQueue.main.async{guard let root=self.viewController?.view else{return callback.error("No window")};let d=args.first as? [String:Any] ?? [:];let frame=NSRect(x:d["x"] as? Double ?? 0,y:root.bounds.height-(d["y"] as? Double ?? 0)-(d["h"] as? Double ?? root.bounds.height),width:d["w"] as? Double ?? root.bounds.width,height:d["h"] as? Double ?? root.bounds.height);let v=NSView(frame:frame);v.wantsLayer=true;v.layer?.backgroundColor=NSColor.black.cgColor;root.addSubview(v);self.overlay=v;self.start(v,callback)}};switch AVCaptureDevice.authorizationStatus(for:.video){case.authorized:begin();case.notDetermined:AVCaptureDevice.requestAccess(for:.video){$0 ? begin():callback.error("Camera permission denied")};default:callback.error("Camera permission denied — enable it in System Settings")}} + private func start(_ view:NSView,_ callback:Callback){guard let device=AVCaptureDevice.default(for:.video),let input=try? AVCaptureDeviceInput(device:device)else{return callback.error("No camera available")};let s=AVCaptureSession();guard s.canAddInput(input)else{return callback.error("Cannot use camera")};s.addInput(input);let out=AVCaptureVideoDataOutput();out.alwaysDiscardsLateVideoFrames=true;guard s.canAddOutput(out)else{return callback.error("Cannot scan QR codes")};s.addOutput(out);out.setSampleBufferDelegate(self,queue:DispatchQueue(label:"shellular.scanner.vision",qos:.userInitiated));let l=AVCaptureVideoPreviewLayer(session:s);l.frame=view.bounds;l.autoresizingMask=[.layerWidthSizable,.layerHeightSizable];l.videoGravity = .resizeAspectFill;view.layer?.addSublayer(l);session=s;layer=l;DispatchQueue.global(qos:.userInitiated).async{s.startRunning();DispatchQueue.main.async{callback.success();self.onShow?.success(nil,keep:true)}}} + private func update(_ args:[Any],_ callback:Callback){guard let v=overlay,let root=viewController?.view,let d=args.first as? [String:Any]else{return callback.error("No scanner")};DispatchQueue.main.async{v.frame=NSRect(x:d["x"] as? Double ?? 0,y:root.bounds.height-(d["y"] as? Double ?? 0)-(d["h"] as? Double ?? 0),width:d["w"] as? Double ?? 0,height:d["h"] as? Double ?? 0);self.layer?.frame=v.bounds;callback.success()}} + private func hide(_ callback:Callback){session?.stopRunning();session=nil;layer?.removeFromSuperlayer();layer=nil;overlay?.removeFromSuperview();overlay=nil;scanCallback=nil;isProcessingFrame=false;callback.success();onHide?.success(nil,keep:true)} + func captureOutput(_ output:AVCaptureOutput,didOutput sampleBuffer:CMSampleBuffer,from connection:AVCaptureConnection){guard scanCallback != nil,!isProcessingFrame,let buffer=CMSampleBufferGetImageBuffer(sampleBuffer)else{return};isProcessingFrame=true;let request=VNDetectBarcodesRequest{[weak self] request,_ in guard let self else{return};defer{self.isProcessingFrame=false};let values=(request.results as? [VNBarcodeObservation])?.filter{$0.symbology == .qr}.compactMap(\.payloadStringValue) ?? [];guard !values.isEmpty else{return};let results=values.map{["rawValue":$0,"displayValue":$0,"format":"256","valueType":"0"]};DispatchQueue.main.async{self.scanCallback?.success(results,keep:!self.once);if self.once{self.scanCallback=nil;self.hide(Callback(id:-1,webView:nil))}}};request.symbologies=[.qr];try? VNImageRequestHandler(cvPixelBuffer:buffer,orientation:.up).perform([request])} +} diff --git a/platforms/macos/shellular/Services.swift b/platforms/macos/shellular/Services.swift new file mode 100644 index 0000000..f2926b1 --- /dev/null +++ b/platforms/macos/shellular/Services.swift @@ -0,0 +1,85 @@ +import AppKit +import AVFoundation +import CryptoKit +import Photos +import Security +import UserNotifications + +final class NativeService: BaseService { + private lazy var socket = SocketService(bridge: bridge!) + override func exec(action: String, args: [Any], callback: Callback) { + switch action { + case "shareFile": share(items: args.compactMap { ($0 as? String).map { URL(fileURLWithPath: $0) } }, callback) + case "shareText": share(items: [args[safe: 0] as? String ?? ""], callback) + case "getAppInfo": callback.success(["label": Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String ?? "Shellular", "packageName": Bundle.main.bundleIdentifier ?? "", "versionName": Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "", "versionCode": Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "0", "platform":"macos"]) + case "getDeviceInfo": callback.success(["manufacturer":"Apple", "model": Host.current().localizedName ?? "Mac", "product": ProcessInfo.processInfo.operatingSystemVersionString, "isEmulator":false]) + case "openInBrowser": open(args, callback) + case "setIntentHandler": AppDelegate.shared?.deepLinkHandler = { callback.success($0.absoluteString, keep: true) }; callback.success(nil, keep: true) + case "setTheme": if let value = args.first as? [String:Any], value["type"] as? String == "light" { NSApp.appearance = NSAppearance(named: .aqua) } else { NSApp.appearance = NSAppearance(named: .darkAqua) }; callback.success() + case "requestPermission": permission(args, request: true, callback) + case "requestPermissions": requestMany(args, callback) + case "hasPermission": permission(args, request: false, callback) + case "captureFromCamera": callback.error("Use the Scanner service for camera capture on macOS") + case "getIpAddresses": callback.success(ipAddresses()) + case "exitApp": DispatchQueue.main.async { NSApp.terminate(nil) }; callback.success() + case "restartApp": callback.success(); DispatchQueue.main.async { let url = Bundle.main.bundleURL; NSWorkspace.shared.openApplication(at: url, configuration: .init()) { _,_ in NSApp.terminate(nil) } } + case "getVersionSdkInt": callback.success(0) + case "setSystemBarColor", "hideSplashScreen", "haptic", "requestIgnoreBatteryOptimization", "setKeyboardSuggestionsEnabled": callback.success() + case "getConfiguration": callback.success(["keyboard":false, "orientation":"landscape", "locale":Locale.current.identifier.replacingOccurrences(of: "_", with: "-"), "fontScale":1]) + case "startSocketService", "stopSocketService", "isSocketServiceRunning", "postMessageToSocketService", "setAppPaused", "setSocketServiceOnMessageListener", "setSocketServiceOnConnectListener", "setSocketServiceOnDisconnectListener", "setSocketServiceOnErrorListener", "setNewOrderNotificationTitleTemplate", "setNewOrderNotificationMessageTemplate": socket.exec(action: action, args: args, callback: callback) + default: callback.error("Unknown action: \(action)") + } + } + private func share(items: [Any], _ callback: Callback) { DispatchQueue.main.async { guard let view = self.viewController?.view else { return callback.error("No window") }; NSSharingServicePicker(items: items).show(relativeTo: view.bounds, of: view, preferredEdge: .minY); callback.success() } } + private func open(_ args: [Any], _ callback: Callback) { guard let s = args.first as? String, let u = URL(string:s) else { return callback.error("Invalid URL") }; NSWorkspace.shared.open(u); callback.success() } + private func permission(_ args: [Any], request: Bool, _ callback: Callback) { + let p = (args.first as? String ?? "").lowercased() + if p.contains("camera") { + if request { AVCaptureDevice.requestAccess(for: .video) { callback.success($0 ? 1 : 0) } } + else { callback.success(AVCaptureDevice.authorizationStatus(for: .video) == .authorized ? 1 : 0) } + } else if p.contains("microphone") { + if request { AVCaptureDevice.requestAccess(for: .audio) { callback.success($0 ? 1 : 0) } } + else { callback.success(AVCaptureDevice.authorizationStatus(for: .audio) == .authorized ? 1 : 0) } + } else if p.contains("notification") { + if request { UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { granted, _ in callback.success(granted ? 1 : 0) } } + else { UNUserNotificationCenter.current().getNotificationSettings { callback.success($0.authorizationStatus == .authorized ? 1 : 0) } } + } else { callback.success(1) } + } + private func requestMany(_ args: [Any], _ callback: Callback) { + guard let values = args.first as? [String] else { return callback.error("Permissions required") } + // Mobile-only permission names are granted no-ops on macOS. Camera and + // microphone are requested by their consuming services before use. + callback.success(values.map { _ in 1 }) + } + private func ipAddresses() -> [String] { var out:[String]=[]; var ptr:UnsafeMutablePointer?; guard getifaddrs(&ptr)==0 else{return out}; defer{freeifaddrs(ptr)}; var p=ptr; while let x=p { defer{p=x.pointee.ifa_next}; let family=x.pointee.ifa_addr.pointee.sa_family; if family==UInt8(AF_INET)||family==UInt8(AF_INET6){ var h=[CChar](repeating:0,count:Int(NI_MAXHOST)); getnameinfo(x.pointee.ifa_addr,socklen_t(x.pointee.ifa_addr.pointee.sa_len),&h,socklen_t(NI_MAXHOST),nil,0,NI_NUMERICHOST); let s=String(cString:h); if s != "127.0.0.1" && s != "::1" { out.append(s) } } }; return out } +} + +final class DeviceService: BaseService { override func exec(action:String,args:[Any],callback:Callback) { guard action=="id" else{return callback.error("Unknown action")}; let id = UserDefaults.standard.string(forKey:"device-id") ?? UUID().uuidString; UserDefaults.standard.set(id,forKey:"device-id"); callback.success("Apple_Mac_\(id)") } } + +final class DialogService: BaseService { + override func exec(action:String,args:[Any],callback:Callback) { DispatchQueue.main.async { let alert=NSAlert(); alert.messageText=args[safe:0] as? String ?? "Shellular"; alert.informativeText=args[safe:1] as? String ?? ""; if action == "prompt" { let field=NSTextField(string:args[safe:2] as? String ?? ""); field.frame.size.width=280; alert.accessoryView=field; alert.addButton(withTitle:"OK"); alert.addButton(withTitle:"Cancel"); callback.success(alert.runModal() == .alertFirstButtonReturn ? field.stringValue:nil) } else { alert.addButton(withTitle:"OK"); if action == "confirm"{alert.addButton(withTitle:"Cancel")}; callback.success(action == "confirm" ? alert.runModal() == .alertFirstButtonReturn : { alert.runModal(); return true }()) } } } +} + +final class EncryptionService: BaseService { override func exec(action:String,args:[Any],callback:Callback) { guard let text=args[safe:0] as? String, let password=args[safe:1] as? String, !password.isEmpty else{return callback.error("message and password required")}; let key=Array(password.utf8); if action=="encrypt" { callback.success(Data(Array(text.utf8).enumerated().map{$0.element ^ key[$0.offset % key.count]}).base64EncodedString()) } else if action=="decrypt", let data=Data(base64Encoded:text), let value=String(bytes:data.enumerated().map{$0.element ^ key[$0.offset % key.count]},encoding:.utf8){callback.success(value)} else {callback.error("Invalid encrypted data")} } } + +final class SecureStoreService: BaseService { override func exec(action:String,args:[Any],callback:Callback) { guard let key=args.first as? String else{return callback.error("key required")}; let query:[String:Any]=[kSecClass as String:kSecClassGenericPassword,kSecAttrService as String:"io.foxbiz.shellular.secure-store",kSecAttrAccount as String:key]; if action=="remove"{SecItemDelete(query as CFDictionary);return callback.success()}; if action=="get"{var item:CFTypeRef?;var q=query;q[kSecReturnData as String]=true; let s=SecItemCopyMatching(q as CFDictionary,&item); return callback.success(s==errSecSuccess ? (item as? Data).flatMap{String(data:$0,encoding:.utf8)}:nil)}; guard let value=args[safe:1] as? String, let data=value.data(using:.utf8) else{return callback.error("value required")}; SecItemDelete(query as CFDictionary);var q=query;q[kSecValueData as String]=data; SecItemAdd(q as CFDictionary,nil)==errSecSuccess ? callback.success():callback.error("Keychain write failed") } } + +final class NotificationService: BaseService { + static let shared = NotificationService(); private var entries:[Int:UNMutableNotificationContent]=[:]; private var listeners:[Int:Callback]=[:]; private var next=1 + private init(){super.init(bridge:Bridge())}; required init(bridge:Bridge){super.init(bridge:bridge)} + static func sharedInstance(bridge:Bridge)->NotificationService { shared.bridge=bridge; return shared } + override func exec(action:String,args:[Any],callback:Callback){switch action{case"create":let c=UNMutableNotificationContent();c.title=args[safe:0] as? String ?? "";c.body=args[safe:1] as? String ?? "";c.sound = .default;let id=next;next+=1;entries[id]=c;callback.success(id);case"show":guard let id=args.first as? Int,let c=entries[id]else{return callback.error("Invalid id")};UNUserNotificationCenter.current().add(.init(identifier:String(id),content:c,trigger:nil)){ $0 == nil ? callback.success():callback.error($0!.localizedDescription)};case"hide":if let id=args.first as? Int{UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers:[String(id)])};callback.success();case"delete":if let id=args.first as? Int{entries[id]=nil;listeners[id]=nil;UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers:[String(id)])};callback.success();case"addListener":if let id=args.first as? Int{listeners[id]=callback};callback.success(nil,keep:true);default:callback.error("Unknown action")}} + func handleTap(_ identifier:String){if let id=Int(identifier){listeners[id]?.success(id,keep:true)}} +} + +final class FileHandlerService: BaseService { + private var root:URL{FileManager.default.urls(for:.applicationSupportDirectory,in:.userDomainMask)[0].appendingPathComponent("Shellular",isDirectory:true)} + /// Bridge paths are virtual app-storage paths. A leading slash means the + /// root of Shellular's private storage, not the root of the startup disk. + private func url(_ path:String)->URL { + let relative = path.hasPrefix("/") ? String(path.dropFirst()) : path + return root.appendingPathComponent(relative) + } + override func exec(action:String,args:[Any],callback:Callback){let fm=FileManager.default;let path=args.first as? String ?? "";let u=url(path);do{try fm.createDirectory(at:root,withIntermediateDirectories:true);switch action{case"read":let d=try Data(contentsOf:u);callback.successBinary(d);case"write":guard let s=args[safe:1] as? String else{throw CocoaError(.fileWriteUnknown)};let data=Data(base64Encoded:s) ?? Data(s.utf8);try data.write(to:u);callback.success();case"delete":try fm.removeItem(at:u);callback.success();case"move":try fm.moveItem(at:u,to:url(args[safe:1] as? String ?? ""));callback.success();case"copy":try fm.copyItem(at:u,to:url(args[safe:1] as? String ?? ""));callback.success();case"list":callback.success(try fm.contentsOfDirectory(atPath:u.path));case"exists":callback.success(fm.fileExists(atPath:u.path) ? 1:0);case"isDirectory":var d:ObjCBool=false;let e=fm.fileExists(atPath:u.path,isDirectory:&d);callback.success(e && d.boolValue ? 1:0);case"isFile":var d:ObjCBool=false;let e=fm.fileExists(atPath:u.path,isDirectory:&d);callback.success(e && !d.boolValue ? 1:0);case"createDirectory":try fm.createDirectory(at:u,withIntermediateDirectories:true);callback.success();case"createFile":fm.createFile(atPath:u.path,contents:nil);callback.success();case"getMetadata":let a=try fm.attributesOfItem(atPath:u.path);callback.success(["size":a[.size] as? Int ?? 0,"modified":(a[.modificationDate] as? Date)?.timeIntervalSince1970 ?? 0]);case"resolve":callback.success(u.path);case"toUrl":callback.success(u.absoluteString);case"reveal":NSWorkspace.shared.activateFileViewerSelecting([u]);callback.success(u.path);case"saveToDevice":save(u,callback);case"print":NSWorkspace.shared.open(u);callback.success();case"download":guard let s=args[safe:1] as? String,let remote=URL(string:s)else{return callback.error("URL required")};URLSession.shared.downloadTask(with:remote){temp,_,error in guard let temp else{return callback.error(error?.localizedDescription ?? "Download failed")};do{try? fm.removeItem(at:u);try fm.moveItem(at:temp,to:u);callback.success(u.path)}catch{callback.error(error.localizedDescription)}}.resume();default:callback.error("Unknown action: \(action)")}}catch{callback.error(error.localizedDescription)}} + private func save(_ source:URL,_ callback:Callback){DispatchQueue.main.async{let p=NSSavePanel();p.nameFieldStringValue=source.lastPathComponent;p.begin{guard $0 == .OK,let dst=p.url else{return callback.error("Cancelled")};do{try? FileManager.default.removeItem(at:dst);try FileManager.default.copyItem(at:source,to:dst);callback.success(dst.path)}catch{callback.error(error.localizedDescription)}}}} +} diff --git a/platforms/macos/shellular/Shellular.icon/Assets/logo.svg b/platforms/macos/shellular/Shellular.icon/Assets/logo.svg new file mode 100644 index 0000000..ee7fc36 --- /dev/null +++ b/platforms/macos/shellular/Shellular.icon/Assets/logo.svg @@ -0,0 +1,27 @@ + + + + diff --git a/platforms/macos/shellular/Shellular.icon/icon.json b/platforms/macos/shellular/Shellular.icon/icon.json new file mode 100644 index 0000000..9e99d2e --- /dev/null +++ b/platforms/macos/shellular/Shellular.icon/icon.json @@ -0,0 +1,74 @@ +{ + "fill-specializations" : [ + { + "value" : { + "linear-gradient" : [ + "display-p3:0.17967,0.14260,0.13601,1.00000", + "display-p3:0.04286,0.04315,0.04894,1.00000" + ], + "orientation" : { + "start" : { + "x" : 0.5, + "y" : 1 + }, + "stop" : { + "x" : 0.5, + "y" : 0.3 + } + } + } + }, + { + "appearance" : "dark", + "value" : { + "linear-gradient" : [ + "display-p3:0.11850,0.09397,0.09067,1.00000", + "display-p3:0.00000,0.00000,0.00000,1.00000" + ], + "orientation" : { + "start" : { + "x" : 0.5, + "y" : 1 + }, + "stop" : { + "x" : 0.5, + "y" : 0.3 + } + } + } + } + ], + "groups" : [ + { + "layers" : [ + { + "fill" : "automatic", + "glass" : false, + "hidden" : false, + "image-name" : "logo.svg", + "name" : "logo", + "position" : { + "scale" : 1.79, + "translation-in-points" : [ + 0, + 0 + ] + } + } + ], + "shadow" : { + "kind" : "neutral", + "opacity" : 0.5 + }, + "translucency" : { + "enabled" : true, + "value" : 0.5 + } + } + ], + "supported-platforms" : { + "squares" : [ + "iOS" + ] + } +} \ No newline at end of file diff --git a/platforms/macos/shellular/SocketService.swift b/platforms/macos/shellular/SocketService.swift new file mode 100644 index 0000000..f663dad --- /dev/null +++ b/platforms/macos/shellular/SocketService.swift @@ -0,0 +1,277 @@ +import Foundation +import UserNotifications + +/// Foreground-only Socket.IO v4 / EIO4 client built on URLSessionWebSocketTask. +/// No external dependencies — the EIO4 / SIO v4 protocol is implemented directly. +final class SocketService: BaseService { + + private var webSocketTask: URLSessionWebSocketTask? + private var urlSession: URLSession? + private var pingTimer: Timer? + private var pingInterval: TimeInterval = 25 + private var authJSON: String? + private var isConnected = false + private var isAppPaused = false + + private var onMessageCallback: Callback? + private var onConnectCallback: Callback? + private var onDisconnectCallback: Callback? + private var onErrorCallback: Callback? + + private var titleTemplate = "New Order #$id" + private var bodyTemplate = "Amount: $amount" + + override func exec(action: String, args: [Any], callback: Callback) { + switch action { + case "startSocketService": + startSocket(args: args, callback: callback) + case "stopSocketService": + stopSocket(args: args, callback: callback) + case "isSocketServiceRunning": + callback.success(isConnected ? 1 : 0) + case "postMessageToSocketService": + postMessage(args: args, callback: callback) + case "setAppPaused": + isAppPaused = boolArg(args, index: 0) + callback.success() + case "setSocketServiceOnMessageListener": + onMessageCallback = callback + callback.success(nil, keep: true) + case "setSocketServiceOnConnectListener": + onConnectCallback = callback + callback.success(nil, keep: true) + case "setSocketServiceOnDisconnectListener": + onDisconnectCallback = callback + callback.success(nil, keep: true) + case "setSocketServiceOnErrorListener": + onErrorCallback = callback + callback.success(nil, keep: true) + case "setNewOrderNotificationTitleTemplate": + if let t = args[safe: 0] as? String { titleTemplate = t } + callback.success() + case "setNewOrderNotificationMessageTemplate": + if let t = args[safe: 0] as? String { bodyTemplate = t } + callback.success() + default: + callback.error("Unknown action: \(action)") + } + } + + // MARK: - Start / Stop + + private func startSocket(args: [Any], callback: Callback) { + guard let urlString = args[safe: 0] as? String else { + callback.error("url required"); return + } + authJSON = args[safe: 1] as? String + guard let wsURL = buildWebSocketURL(from: urlString) else { + callback.error("Invalid URL: \(urlString)"); return + } + + // Tear down any existing connection + tearDown() + + let config = URLSessionConfiguration.default + // Allow plain-text sockets in development; production should use wss:// + config.tlsMinimumSupportedProtocolVersion = .TLSv10 + urlSession = URLSession(configuration: config) + webSocketTask = urlSession?.webSocketTask(with: wsURL) + webSocketTask?.resume() + startReceiving() + callback.success() + } + + private func stopSocket(args: [Any], callback: Callback) { + tearDown() + callback.success() + } + + private func tearDown() { + pingTimer?.invalidate() + pingTimer = nil + webSocketTask?.cancel(with: .goingAway, reason: nil) + webSocketTask = nil + urlSession?.invalidateAndCancel() + urlSession = nil + if isConnected { + isConnected = false + onDisconnectCallback?.success(nil, keep: true) + } + } + + // MARK: - Receive loop + + private func startReceiving() { + webSocketTask?.receive { [weak self] result in + guard let self = self else { return } + switch result { + case .success(.string(let text)): + self.handlePacket(text) + self.startReceiving() + case .success(.data(let data)): + if let text = String(data: data, encoding: .utf8) { self.handlePacket(text) } + self.startReceiving() + case .failure(let error): + self.handleDisconnect(reason: error.localizedDescription) + @unknown default: + self.startReceiving() + } + } + } + + // MARK: - EIO4 packet handling + + private func handlePacket(_ text: String) { + guard let first = text.first else { return } + let rest = String(text.dropFirst()) + switch String(first) { + case "0": // EIO OPEN + if let data = rest.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { + pingInterval = (json["pingInterval"] as? Double ?? 25000) / 1000 + } + // Send SIO CONNECT with optional auth + let connectPacket: String + if let auth = authJSON, !auth.isEmpty { + connectPacket = "40{\"auth\":\(auth)}" + } else { + connectPacket = "40" + } + sendRaw(connectPacket) + schedulePingTimer() + + case "1": // EIO CLOSE + handleDisconnect(reason: "Server closed connection") + + case "2": // EIO PING — server-initiated, respond with PONG + sendRaw("3") + + case "4": // EIO MESSAGE — contains SIO packet + handleSIOPacket(rest) + + default: + break + } + } + + // MARK: - SIO packet handling + + private func handleSIOPacket(_ text: String) { + guard let first = text.first else { return } + let rest = String(text.dropFirst()) + switch String(first) { + case "0": // SIO CONNECT ACK + isConnected = true + onConnectCallback?.success(nil, keep: true) + + case "1": // SIO DISCONNECT + handleDisconnect(reason: nil) + + case "2": // SIO EVENT + guard let data = rest.data(using: .utf8), + let arr = try? JSONSerialization.jsonObject(with: data) as? [Any], + let name = arr.first as? String, + name == "message", + arr.count > 1 else { return } + let payload = arr[1] + onMessageCallback?.success(payload, keep: true) + if isAppPaused { postBackgroundNotification(for: payload) } + + case "4": // SIO CONNECT_ERROR + onErrorCallback?.success(rest, keep: true) + + default: + break + } + } + + // MARK: - Send + + private func postMessage(args: [Any], callback: Callback) { + guard let message = args[safe: 0] else { callback.error("message required"); return } + let json: String + if let str = message as? String { + // Wrap raw string as a JSON string value + guard let data = try? JSONSerialization.data(withJSONObject: str), + let s = String(data: data, encoding: .utf8) else { + callback.error("Failed to serialize message"); return + } + json = s + } else if let data = try? JSONSerialization.data(withJSONObject: message), + let s = String(data: data, encoding: .utf8) { + json = s + } else { + callback.error("Failed to serialize message"); return + } + sendRaw("42[\"message\",\(json)]") + callback.success() + } + + private func sendRaw(_ text: String) { + webSocketTask?.send(.string(text)) { [weak self] error in + if let error = error { + self?.onErrorCallback?.success(error.localizedDescription, keep: true) + } + } + } + + // MARK: - Ping timer (client-side keep-alive) + + private func schedulePingTimer() { + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + self.pingTimer?.invalidate() + self.pingTimer = Timer.scheduledTimer(withTimeInterval: self.pingInterval, repeats: true) { [weak self] _ in + self?.sendRaw("2") + } + } + } + + // MARK: - Disconnect + + private func handleDisconnect(reason: String?) { + guard isConnected || webSocketTask != nil else { return } + isConnected = false + pingTimer?.invalidate() + pingTimer = nil + onDisconnectCallback?.success(reason, keep: true) + } + + // MARK: - Background notification + + private func postBackgroundNotification(for payload: Any) { + var id = "" + var amount = "" + if let dict = payload as? [String: Any] { + id = "\(dict["id"] ?? "")" + amount = "\(dict["amount"] ?? "")" + } + let title = titleTemplate.replacingOccurrences(of: "$id", with: id) + let body = bodyTemplate.replacingOccurrences(of: "$amount", with: amount) + let content = UNMutableNotificationContent() + content.title = title + content.body = body + content.sound = .default + let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil) + UNUserNotificationCenter.current().add(request) + } + + // MARK: - Helpers + + private func buildWebSocketURL(from string: String) -> URL? { + guard var comps = URLComponents(string: string) else { return nil } + if comps.scheme == "http" || comps.scheme == "https" { comps.scheme = "ws" } + var items = comps.queryItems ?? [] + items.removeAll { $0.name == "EIO" || $0.name == "transport" } + items.append(URLQueryItem(name: "EIO", value: "4")) + items.append(URLQueryItem(name: "transport", value: "websocket")) + comps.queryItems = items + return comps.url + } + + private func boolArg(_ args: [Any], index: Int) -> Bool { + if let b = args[safe: index] as? Bool { return b } + if let i = args[safe: index] as? Int { return i == 1 } + return false + } +} diff --git a/platforms/macos/shellular/WebView.swift b/platforms/macos/shellular/WebView.swift new file mode 100644 index 0000000..74a34d9 --- /dev/null +++ b/platforms/macos/shellular/WebView.swift @@ -0,0 +1,68 @@ +import SwiftUI +import AppKit +import WebKit + +struct WebContainer: NSViewControllerRepresentable { + func makeNSViewController(context: Context) -> WebViewController { WebViewController() } + func updateNSViewController(_ controller: WebViewController, context: Context) {} +} + +final class WebViewController: NSViewController, WKNavigationDelegate, WKUIDelegate { + private(set) var webView: WKWebView! + let bridge = Bridge() + private let errorLabel = NSTextField(labelWithString: "") + + override func loadView() { view = NSView(frame: NSRect(x: 0, y: 0, width: 1100, height: 760)) } + override func viewDidLoad() { + super.viewDidLoad() + let configuration = WKWebViewConfiguration() + configuration.setURLSchemeHandler(BundleSchemeHandler(), forURLScheme: "shellular") + configuration.userContentController.add(bridge, name: "exec") + webView = WKWebView(frame: .zero, configuration: configuration) + webView.navigationDelegate = self; webView.uiDelegate = self; webView.isInspectable = true + webView.setValue(false, forKey: "drawsBackground") + webView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(webView) + NSLayoutConstraint.activate([webView.leadingAnchor.constraint(equalTo: view.leadingAnchor), webView.trailingAnchor.constraint(equalTo: view.trailingAnchor), webView.topAnchor.constraint(equalTo: view.topAnchor), webView.bottomAnchor.constraint(equalTo: view.bottomAnchor)]) + errorLabel.alignment = .center; errorLabel.maximumNumberOfLines = 4; errorLabel.isHidden = true + errorLabel.translatesAutoresizingMaskIntoConstraints = false; view.addSubview(errorLabel) + NSLayoutConstraint.activate([errorLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor), errorLabel.centerYAnchor.constraint(equalTo: view.centerYAnchor), errorLabel.widthAnchor.constraint(lessThanOrEqualToConstant: 620)]) + bridge.setup(webView: webView, viewController: self) + webView.load(URLRequest(url: URL(string: "shellular://localhost/")!)) + NotificationCenter.default.addObserver(forName: .reloadWebView, object: nil, queue: .main) { [weak self] _ in self?.webView.reload() } + NotificationCenter.default.addObserver(forName: .openFile, object: nil, queue: .main) { [weak self] _ in + guard let self, let window = self.view.window else { return } + let panel = NSOpenPanel(); panel.allowsMultipleSelection = true + panel.beginSheetModal(for: window) { response in + guard response == .OK, + let data = try? JSONSerialization.data(withJSONObject: panel.urls.map(\.path)), + let json = String(data: data, encoding: .utf8) else { return } + self.webView.evaluateJavaScript("window.dispatchEvent(new CustomEvent('macosopenfiles',{detail:\(json)}))") + } + } + NotificationCenter.default.addObserver(forName: NSApplication.didResignActiveNotification, object: nil, queue: .main) { [weak self] _ in self?.webView.evaluateJavaScript("document.dispatchEvent(new CustomEvent('pause'))") } + NotificationCenter.default.addObserver(forName: NSApplication.didBecomeActiveNotification, object: nil, queue: .main) { [weak self] _ in self?.webView.evaluateJavaScript("document.dispatchEvent(new CustomEvent('resume'))") } + } + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { errorLabel.isHidden = true } + func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { show(error) } + func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { show(error) } + private func show(_ error: Error) { errorLabel.stringValue = "Shellular could not load its interface.\n\(error.localizedDescription)"; errorLabel.isHidden = false } + func webView(_ webView: WKWebView, runOpenPanelWith parameters: WKOpenPanelParameters, initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping ([URL]?) -> Void) { + let panel = NSOpenPanel(); panel.allowsMultipleSelection = parameters.allowsMultipleSelection; panel.canChooseDirectories = parameters.allowsDirectories + panel.beginSheetModal(for: view.window!) { completionHandler($0 == .OK ? panel.urls : nil) } + } +} + +final class BundleSchemeHandler: NSObject, WKURLSchemeHandler { + func webView(_ webView: WKWebView, start task: WKURLSchemeTask) { + guard let url = task.request.url else { return task.didFailWithError(URLError(.badURL)) } + let name = url.path == "/" || url.path.isEmpty ? "index.html" : String(url.path.dropFirst()) + guard let root = Bundle.main.resourceURL else { return task.didFailWithError(URLError(.fileDoesNotExist)) } + let file = root.appendingPathComponent("bundle").appendingPathComponent(name) + guard let data = try? Data(contentsOf: file) else { return task.didFailWithError(URLError(.fileDoesNotExist)) } + let types = ["html":"text/html", "js":"application/javascript", "css":"text/css", "json":"application/json", "svg":"image/svg+xml", "png":"image/png", "ttf":"font/ttf", "woff2":"font/woff2"] + let response = HTTPURLResponse(url: url, statusCode: 200, httpVersion: "HTTP/1.1", headerFields: ["Content-Type": types[file.pathExtension] ?? "application/octet-stream"])! + task.didReceive(response); task.didReceive(data); task.didFinish() + } + func webView(_ webView: WKWebView, stop task: WKURLSchemeTask) {} +} diff --git a/platforms/macos/shellular/shellular.entitlements b/platforms/macos/shellular/shellular.entitlements new file mode 100644 index 0000000..85f079c --- /dev/null +++ b/platforms/macos/shellular/shellular.entitlements @@ -0,0 +1,4 @@ + + +com.apple.security.app-sandboxcom.apple.security.device.cameracom.apple.security.device.microphonecom.apple.security.files.user-selected.read-writecom.apple.security.network.clientcom.apple.security.network.server + diff --git a/src/boot.ts b/src/boot.ts index c4fc6e3..ced3074 100644 --- a/src/boot.ts +++ b/src/boot.ts @@ -42,6 +42,8 @@ import { initHapticDelegation } from "lib/haptics"; setup = await import("./platforms/android"); } else if (process.env.IS_IOS) { setup = await import("./platforms/ios"); + } else if (process.env.IS_MACOS) { + setup = await import("./platforms/macos"); } else if (process.env.IS_BROWSER) { setup = await import("./platforms/browser"); } diff --git a/src/index.d.ts b/src/index.d.ts index a3eb9db..667331b 100644 --- a/src/index.d.ts +++ b/src/index.d.ts @@ -1,4 +1,4 @@ -type PLATFORM = "android" | "browser" | "ios"; +type PLATFORM = "android" | "browser" | "ios" | "macos"; interface Bridge { exec( @@ -15,6 +15,7 @@ declare namespace NodeJS { env: { ID: string; IS_IOS: boolean; + IS_MACOS: boolean; IS_ANDROID: boolean; IS_BROWSER: boolean; DEV_MODE: boolean; diff --git a/src/main.ts b/src/main.ts index 2b0d468..7529440 100644 --- a/src/main.ts +++ b/src/main.ts @@ -158,7 +158,7 @@ async function load() { const versionCode = Number.parseInt(platform.versionCode, 10); if (versionCode > Number.parseInt(process.env.VERSION_CODE, 10)) { const confirmation = await dialog.confirm( - `New version ${platform.version} available on ${process.env.IS_IOS ? "App Store" : "Play Store"}, update?`, + `New version ${platform.version} available on ${process.env.IS_IOS || process.env.IS_MACOS ? "App Store" : "Play Store"}, update?`, "New version available", ); diff --git a/src/platforms/macos/index.ts b/src/platforms/macos/index.ts new file mode 100644 index 0000000..d41fa58 --- /dev/null +++ b/src/platforms/macos/index.ts @@ -0,0 +1 @@ +export { default } from "../ios"; diff --git a/src/state/connection.ts b/src/state/connection.ts index 1fb84ed..b8f680f 100644 --- a/src/state/connection.ts +++ b/src/state/connection.ts @@ -243,7 +243,9 @@ export class Connection extends EventTarget { hostId, clientId, appVersion, - platform: process.env.PLATFORM, + // The sibling protocol package adds macOS in this change. Keep this cast + // until the newly published protocol declaration is installed here. + platform: process.env.PLATFORM as ClientInfoRequest["platform"], deviceModel: deviceInfo.model, deviceIsEmulator: deviceInfo.isEmulator, deviceManufacturer: deviceInfo.manufacturer, diff --git a/webpack.config.js b/webpack.config.js index f47ef42..7a25c5a 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -14,6 +14,7 @@ const PLATFORMS = join(process.cwd(), "platforms"); const ANDROID_BUNDLE = join(PLATFORMS, "android/app/src/main/assets/bundle"); const BROWSER_BUNDLE = join(PLATFORMS, "browser/bundle"); const IOS_BUNDLE = join(PLATFORMS, "ios/shellular/bundle"); +const MACOS_BUNDLE = join(PLATFORMS, "macos/shellular/bundle"); const MIXINS = `@use "${join(process.cwd(), "src/mixins.scss")}" as *;`; if (!existsSync(ANDROID_BUNDLE)) { @@ -27,6 +28,9 @@ if (!existsSync(BROWSER_BUNDLE)) { if (!existsSync(IOS_BUNDLE)) { mkdirSync(IOS_BUNDLE, { recursive: true }); } +if (!existsSync(MACOS_BUNDLE)) { + mkdirSync(MACOS_BUNDLE, { recursive: true }); +} export default (_, { env = {}, mode = "development" }) => { const isDev = mode === "development"; @@ -41,6 +45,8 @@ export default (_, { env = {}, mode = "development" }) => { ? BROWSER_BUNDLE : platform === "ios" ? IOS_BUNDLE + : platform === "macos" + ? MACOS_BUNDLE : ANDROID_BUNDLE; let alias; @@ -249,6 +255,7 @@ export default (_, { env = {}, mode = "development" }) => { ID: packageJson.name, VERSION: packageJson.version, IS_IOS: platform === "ios", + IS_MACOS: platform === "macos", IS_BROWSER: platform === "browser", IS_ANDROID: platform === "android", VERSION_CODE: packageJson.versionCode, From dd5f70be2b5a497045cef7823c06255671ecd2fd Mon Sep 17 00:00:00 2001 From: Biraj Date: Mon, 13 Jul 2026 13:40:46 -0700 Subject: [PATCH 2/2] chore: update android --- platforms/android/gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/platforms/android/gradle/libs.versions.toml b/platforms/android/gradle/libs.versions.toml index a5e427d..a51e076 100644 --- a/platforms/android/gradle/libs.versions.toml +++ b/platforms/android/gradle/libs.versions.toml @@ -1,5 +1,5 @@ [versions] -agp = "9.2.0" +agp = "9.2.1" barcodeScanning = "18.11.0" browser = "1.8.0" cameraCamera2 = "1.4.2"