Changes
57 changed files (+3/-8476)
-
-
@@ -16,23 +16,12 @@SPDX-License-Identifier: Apache-2.0 --> # Plac # Plac for GTK4 Third-party [Roon](https://roon.app/) clients. ## [Plac for GTK4](./gtk-adwaita/) Third-party [Roon](https://roon.app/) client application using GTK4 and GNOME widgets.  GTK4 application implemented using GNOME widgets. Best suited for Linux, but it also works on other systems where GTK4 and libadwaita are installed. ## [Plac for macOS](./macos/)  SwiftUI application written for Apple platforms. ## License This project is licensed under [Apache-2.0 License](https://www.apache.org/licenses/LICENSE-2.0).
-
@@ -41,18 +30,6 @@Every file has [REUSE][reuse-license] annotation for copyright and license. ## Development ### Internal Packages #### [Core](./core/) Core logic available as C API, written in Zig. This package provides clean API abstracting Roon API and networkings. #### [CLI](./cli/) Simple command line application for testing and profiling core API. Internal only and not intended for real world uses. ### System Dependencies
-
-
assets/screenshot-macos.png (deleted)
-
assets/screenshot-macos.png.license (deleted)
-
@@ -1,3 +0,0 @@Copyright 2025 Shota FUJI SPDX-License-Identifier: CC0-1.0
-
-
-
@@ -15,7 +15,7 @@ ## SPDX-License-Identifier: Apache-2.0 { description = "Plac monorepo"; description = "Plac for GTK4"; inputs = { nixpkgs = {
-
-
macos/.gitignore (deleted)
-
@@ -1,28 +0,0 @@# Copyright 2025 Shota FUJI # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. # # SPDX-License-Identifier: Apache-2.0 # What: XCode's user specifc data. xcuserdata/ # What: Clang header maps. # Why: https://github.com/github/gitignore/blob/main/Swift.gitignore *.hmap # What: Swift Package Manager directories. /*/Packages DerivedData/ .swiftpm/configuration/registries.json .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
-
-
macos/Plac.xcodeproj/project.pbxproj (deleted)
-
@@ -1,506 +0,0 @@// !$*UTF8*$! { archiveVersion = 1; classes = { }; objectVersion = 77; objects = { /* Begin PBXBuildFile section */ C3BD03932E4F079E006AF103 /* RoonKit in Frameworks */ = {isa = PBXBuildFile; productRef = C3BD03922E4F079E006AF103 /* RoonKit */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ C3EABC802DB1170700F786D6 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = C3EABC692DB1170400F786D6 /* Project object */; proxyType = 1; remoteGlobalIDString = C3EABC702DB1170400F786D6; remoteInfo = plac; }; /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ C3BD03912E4EB8F3006AF103 /* RoonKit */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = RoonKit; sourceTree = "<group>"; }; C3C836F42E61DFBA00D285F7 /* Plac.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = Plac.xctestplan; sourceTree = "<group>"; }; C3EABC712DB1170400F786D6 /* Plac.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Plac.app; sourceTree = BUILT_PRODUCTS_DIR; }; C3EABC7F2DB1170700F786D6 /* PlacTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = PlacTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ C3EABC732DB1170400F786D6 /* Plac */ = { isa = PBXFileSystemSynchronizedRootGroup; path = Plac; sourceTree = "<group>"; }; C3EABC822DB1170700F786D6 /* PlacTests */ = { isa = PBXFileSystemSynchronizedRootGroup; path = PlacTests; sourceTree = "<group>"; }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ C3EABC6E2DB1170400F786D6 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( C3BD03932E4F079E006AF103 /* RoonKit in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; C3EABC7C2DB1170700F786D6 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ C3EABC682DB1170400F786D6 = { isa = PBXGroup; children = ( C3C836F42E61DFBA00D285F7 /* Plac.xctestplan */, C3BD03912E4EB8F3006AF103 /* RoonKit */, C3EABC732DB1170400F786D6 /* Plac */, C3EABC822DB1170700F786D6 /* PlacTests */, C3EABC722DB1170400F786D6 /* Products */, ); sourceTree = "<group>"; }; C3EABC722DB1170400F786D6 /* Products */ = { isa = PBXGroup; children = ( C3EABC712DB1170400F786D6 /* Plac.app */, C3EABC7F2DB1170700F786D6 /* PlacTests.xctest */, ); name = Products; sourceTree = "<group>"; }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ C3EABC702DB1170400F786D6 /* Plac */ = { isa = PBXNativeTarget; buildConfigurationList = C3EABC932DB1170700F786D6 /* Build configuration list for PBXNativeTarget "Plac" */; buildPhases = ( C3EABC6D2DB1170400F786D6 /* Sources */, C3EABC6E2DB1170400F786D6 /* Frameworks */, C3EABC6F2DB1170400F786D6 /* Resources */, ); buildRules = ( ); dependencies = ( ); fileSystemSynchronizedGroups = ( C3EABC732DB1170400F786D6 /* Plac */, ); name = Plac; packageProductDependencies = ( C3BD03922E4F079E006AF103 /* RoonKit */, ); productName = plac; productReference = C3EABC712DB1170400F786D6 /* Plac.app */; productType = "com.apple.product-type.application"; }; C3EABC7E2DB1170700F786D6 /* PlacTests */ = { isa = PBXNativeTarget; buildConfigurationList = C3EABC962DB1170700F786D6 /* Build configuration list for PBXNativeTarget "PlacTests" */; buildPhases = ( C3EABC7B2DB1170700F786D6 /* Sources */, C3EABC7C2DB1170700F786D6 /* Frameworks */, C3EABC7D2DB1170700F786D6 /* Resources */, ); buildRules = ( ); dependencies = ( C3EABC812DB1170700F786D6 /* PBXTargetDependency */, ); fileSystemSynchronizedGroups = ( C3EABC822DB1170700F786D6 /* PlacTests */, ); name = PlacTests; packageProductDependencies = ( ); productName = placTests; productReference = C3EABC7F2DB1170700F786D6 /* PlacTests.xctest */; productType = "com.apple.product-type.bundle.unit-test"; }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ C3EABC692DB1170400F786D6 /* Project object */ = { isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = 1; LastSwiftUpdateCheck = 1630; LastUpgradeCheck = 1640; TargetAttributes = { C3EABC702DB1170400F786D6 = { CreatedOnToolsVersion = 16.3; }; C3EABC7E2DB1170700F786D6 = { CreatedOnToolsVersion = 16.3; TestTargetID = C3EABC702DB1170400F786D6; }; }; }; buildConfigurationList = C3EABC6C2DB1170400F786D6 /* Build configuration list for PBXProject "Plac" */; developmentRegion = en; hasScannedForEncodings = 0; knownRegions = ( en, Base, ja, ); mainGroup = C3EABC682DB1170400F786D6; minimizedProjectReferenceProxies = 1; packageReferences = ( ); preferredProjectObjectVersion = 77; productRefGroup = C3EABC722DB1170400F786D6 /* Products */; projectDirPath = ""; projectRoot = ""; targets = ( C3EABC702DB1170400F786D6 /* Plac */, C3EABC7E2DB1170700F786D6 /* PlacTests */, ); }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ C3EABC6F2DB1170400F786D6 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( ); runOnlyForDeploymentPostprocessing = 0; }; C3EABC7D2DB1170700F786D6 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ C3EABC6D2DB1170400F786D6 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( ); runOnlyForDeploymentPostprocessing = 0; }; C3EABC7B2DB1170700F786D6 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ C3EABC812DB1170700F786D6 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = C3EABC702DB1170400F786D6 /* Plac */; targetProxy = C3EABC802DB1170700F786D6 /* PBXContainerItemProxy */; }; /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ C3EABC912DB1170700F786D6 /* 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; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; 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; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; }; name = Debug; }; C3EABC922DB1170700F786D6 /* 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; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 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; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; SWIFT_COMPILATION_MODE = wholemodule; SWIFT_EMIT_LOC_STRINGS = YES; }; name = Release; }; C3EABC942DB1170700F786D6 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = plac/plac.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = ""; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES; "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES; "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphonesimulator*]" = YES; "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphoneos*]" = YES; "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphonesimulator*]" = YES; "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphoneos*]" = UIStatusBarStyleDefault; "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; IPHONEOS_DEPLOYMENT_TARGET = 18.4; LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 15.3; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = jp.pocka.plac; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; REGISTER_APP_GROUPS = YES; SDKROOT = auto; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2,7"; XROS_DEPLOYMENT_TARGET = 2.4; }; name = Debug; }; C3EABC952DB1170700F786D6 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = plac/plac.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = ""; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES; "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES; "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphonesimulator*]" = YES; "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphoneos*]" = YES; "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphonesimulator*]" = YES; "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphoneos*]" = UIStatusBarStyleDefault; "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; IPHONEOS_DEPLOYMENT_TARGET = 18.4; LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 15.3; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = jp.pocka.plac; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; REGISTER_APP_GROUPS = YES; SDKROOT = auto; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2,7"; XROS_DEPLOYMENT_TARGET = 2.4; }; name = Release; }; C3EABC972DB1170700F786D6 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEAD_CODE_STRIPPING = YES; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 18.4; MACOSX_DEPLOYMENT_TARGET = 15.3; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = jp.pocka.placTests; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = auto; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2,7"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Plac.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Plac"; XROS_DEPLOYMENT_TARGET = 2.4; }; name = Debug; }; C3EABC982DB1170700F786D6 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEAD_CODE_STRIPPING = YES; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 18.4; MACOSX_DEPLOYMENT_TARGET = 15.3; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = jp.pocka.placTests; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = auto; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2,7"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Plac.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Plac"; XROS_DEPLOYMENT_TARGET = 2.4; }; name = Release; }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ C3EABC6C2DB1170400F786D6 /* Build configuration list for PBXProject "Plac" */ = { isa = XCConfigurationList; buildConfigurations = ( C3EABC912DB1170700F786D6 /* Debug */, C3EABC922DB1170700F786D6 /* Release */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; C3EABC932DB1170700F786D6 /* Build configuration list for PBXNativeTarget "Plac" */ = { isa = XCConfigurationList; buildConfigurations = ( C3EABC942DB1170700F786D6 /* Debug */, C3EABC952DB1170700F786D6 /* Release */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; C3EABC962DB1170700F786D6 /* Build configuration list for PBXNativeTarget "PlacTests" */ = { isa = XCConfigurationList; buildConfigurations = ( C3EABC972DB1170700F786D6 /* Debug */, C3EABC982DB1170700F786D6 /* Release */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; /* End XCConfigurationList section */ /* Begin XCSwiftPackageProductDependency section */ C3BD03922E4F079E006AF103 /* RoonKit */ = { isa = XCSwiftPackageProductDependency; productName = RoonKit; }; /* End XCSwiftPackageProductDependency section */ }; rootObject = C3EABC692DB1170400F786D6 /* Project object */; }
-
-
-
@@ -1,7 +0,0 @@<?xml version="1.0" encoding="UTF-8"?> <Workspace version = "1.0"> <FileRef location = "self:"> </FileRef> </Workspace>
-
-
-
@@ -1,5 +0,0 @@<?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/> </plist>
-
-
-
@@ -1,42 +0,0 @@{ "originHash" : "d7c8df497b27c4731b91c7c9b5f25500d28e0fd99bf3f6080a72b02c267be0c4", "pins" : [ { "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" : "671108c96644956dddcd89dd59c203dcdb36cec7", "version" : "1.1.4" } }, { "identity" : "swift-nio", "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-nio.git", "state" : { "revision" : "1c30f0f2053b654e3d1302492124aa6d242cdba7", "version" : "2.86.0" } }, { "identity" : "swift-system", "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-system.git", "state" : { "revision" : "a34201439c74b53f0fd71ef11741af7e7caf01e1", "version" : "1.4.2" } } ], "version" : 3 }
-
-
-
@@ -1,107 +0,0 @@<?xml version="1.0" encoding="UTF-8"?> <Scheme LastUpgradeVersion = "1640" version = "1.7"> <BuildAction parallelizeBuildables = "YES" buildImplicitDependencies = "YES" buildArchitectures = "Automatic"> <BuildActionEntries> <BuildActionEntry buildForTesting = "YES" buildForRunning = "YES" buildForProfiling = "YES" buildForArchiving = "YES" buildForAnalyzing = "YES"> <BuildableReference BuildableIdentifier = "primary" BlueprintIdentifier = "C3EABC702DB1170400F786D6" BuildableName = "Plac.app" BlueprintName = "Plac" ReferencedContainer = "container:Plac.xcodeproj"> </BuildableReference> </BuildActionEntry> </BuildActionEntries> </BuildAction> <TestAction buildConfiguration = "Debug" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" shouldUseLaunchSchemeArgsEnv = "YES"> <TestPlans> <TestPlanReference reference = "container:Plac.xctestplan" default = "YES"> </TestPlanReference> </TestPlans> <Testables> <TestableReference skipped = "NO" parallelizable = "YES"> <BuildableReference BuildableIdentifier = "primary" BlueprintIdentifier = "C3EABC7E2DB1170700F786D6" BuildableName = "PlacTests.xctest" BlueprintName = "PlacTests" ReferencedContainer = "container:Plac.xcodeproj"> </BuildableReference> </TestableReference> <TestableReference skipped = "YES" parallelizable = "YES"> <BuildableReference BuildableIdentifier = "primary" BlueprintIdentifier = "C3EABC882DB1170700F786D6" BuildableName = "PlacUITests.xctest" BlueprintName = "PlacUITests" ReferencedContainer = "container:Plac.xcodeproj"> </BuildableReference> </TestableReference> </Testables> </TestAction> <LaunchAction buildConfiguration = "Debug" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" launchStyle = "0" useCustomWorkingDirectory = "NO" ignoresPersistentStateOnLaunch = "NO" debugDocumentVersioning = "YES" debugServiceExtension = "internal" allowLocationSimulation = "YES"> <BuildableProductRunnable runnableDebuggingMode = "0"> <BuildableReference BuildableIdentifier = "primary" BlueprintIdentifier = "C3EABC702DB1170400F786D6" BuildableName = "Plac.app" BlueprintName = "Plac" ReferencedContainer = "container:Plac.xcodeproj"> </BuildableReference> </BuildableProductRunnable> </LaunchAction> <ProfileAction buildConfiguration = "Release" shouldUseLaunchSchemeArgsEnv = "YES" savedToolIdentifier = "" useCustomWorkingDirectory = "NO" debugDocumentVersioning = "YES"> <BuildableProductRunnable runnableDebuggingMode = "0"> <BuildableReference BuildableIdentifier = "primary" BlueprintIdentifier = "C3EABC702DB1170400F786D6" BuildableName = "Plac.app" BlueprintName = "Plac" ReferencedContainer = "container:Plac.xcodeproj"> </BuildableReference> </BuildableProductRunnable> </ProfileAction> <AnalyzeAction buildConfiguration = "Debug"> </AnalyzeAction> <ArchiveAction buildConfiguration = "Release" revealArchiveInOrganizer = "YES"> </ArchiveAction> </Scheme>
-
-
macos/Plac.xctestplan (deleted)
-
@@ -1,29 +0,0 @@{ "configurations" : [ { "id" : "F201ED46-E4DE-45F2-AF60-953E38ED4928", "name" : "Test Scheme Action", "options" : { } } ], "defaultOptions" : { "targetForVariableExpansion" : { "containerPath" : "container:Plac.xcodeproj", "identifier" : "C3EABC702DB1170400F786D6", "name" : "Plac" } }, "testTargets" : [ { "parallelizable" : true, "target" : { "containerPath" : "container:Plac.xcodeproj", "identifier" : "C3EABC7E2DB1170700F786D6", "name" : "PlacTests" } } ], "version" : 1 }
-
-
-
@@ -1,11 +0,0 @@{ "colors": [ { "idiom": "universal" } ], "info": { "author": "xcode", "version": 1 } }
-
-
-
@@ -1,85 +0,0 @@{ "images": [ { "idiom": "universal", "platform": "ios", "size": "1024x1024" }, { "appearances": [ { "appearance": "luminosity", "value": "dark" } ], "idiom": "universal", "platform": "ios", "size": "1024x1024" }, { "appearances": [ { "appearance": "luminosity", "value": "tinted" } ], "idiom": "universal", "platform": "ios", "size": "1024x1024" }, { "idiom": "mac", "scale": "1x", "size": "16x16" }, { "idiom": "mac", "scale": "2x", "size": "16x16" }, { "idiom": "mac", "scale": "1x", "size": "32x32" }, { "idiom": "mac", "scale": "2x", "size": "32x32" }, { "idiom": "mac", "scale": "1x", "size": "128x128" }, { "idiom": "mac", "scale": "2x", "size": "128x128" }, { "idiom": "mac", "scale": "1x", "size": "256x256" }, { "idiom": "mac", "scale": "2x", "size": "256x256" }, { "idiom": "mac", "scale": "1x", "size": "512x512" }, { "idiom": "mac", "scale": "2x", "size": "512x512" } ], "info": { "author": "xcode", "version": 1 } }
-
-
macos/Plac/Assets.xcassets/Contents.json (deleted)
-
@@ -1,6 +0,0 @@{ "info": { "author": "xcode", "version": 1 } }
-
-
-
@@ -1,410 +0,0 @@// Copyright 2025 Shota FUJI // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 import OSLog import RoonKit import SwiftUI struct BrowseItemGroup: Identifiable, Hashable { let title: String? let items: [BrowseService.Item] var id: Int { self.hashValue } init(title: String? = nil, items: [BrowseService.Item]) { self.title = title self.items = items } static func groupInto(items: [BrowseService.Item]) -> [BrowseItemGroup] { var groups: [BrowseItemGroup] = [] var currentTitle: String? var currentItems: [BrowseService.Item] = [] for item in items { if let last = currentItems.last { let isProbablyDifferentItemKind = ((last.imageKey == nil) != (item.imageKey == nil)) || ((last.subtitle == nil) != (item.subtitle == nil)) if isProbablyDifferentItemKind { groups.append(.init(title: currentTitle, items: currentItems)) currentTitle = nil currentItems = [] } } switch item.hint { case .header: if currentTitle != nil || currentItems.count > 0 { groups.append(.init(title: currentTitle, items: currentItems)) } currentItems = [] currentTitle = item.title default: currentItems.append(item) } } if currentTitle != nil || currentItems.count > 0 { groups.append(.init(title: currentTitle, items: currentItems)) } return groups } } enum BrowsePage { case loading case loaded(BrowseService.List, [BrowseItemGroup]) case failed(any Error) } extension BrowsePage { func isLoading() -> Bool { switch self { case .loading: true default: false } } } @MainActor @Observable final class BrowsingDataModel { @ObservationIgnored private let logger = Logger() @ObservationIgnored private var loading: Task<Void, Never>? = nil private let id: UUID = .init() var hierarchy: BrowseService.Hierarchy? = nil { didSet { stack = [] } } var zone: TransportService.Zone? = nil private var _stack: [BrowseService.Item] = [] var stack: [BrowseService.Item] { get { return _stack } set { let oldValue = _stack _stack = newValue guard let last = _stack.last else { loadRootPage() return } if oldValue.count > _stack.count { popPages(items: Array(oldValue[_stack.count...])) return } loadPage(item: last) } } var rootPage: BrowsePage? = nil var pages: [BrowseService.Item: BrowsePage] = [:] var message: String? = nil @ObservationIgnored private let conn: Communicatable init(conn: Communicatable) { self.conn = conn } enum LoadError: Error { case hierarchyNotSet case nonListResponse } private func loadRootPage() { guard let hierarchy = hierarchy else { return } if let loading = self.loading { loading.cancel() } pages = [:] self.loading = Task { rootPage = .loading do { logger.debug("Moving browse cursor to root on \(hierarchy.rawValue)") let browse = try BrowseService.BrowseResponse( try await conn.request( Moo( browse: .init( hierarchy: hierarchy, multiSessionKey: id.uuidString, zoneOrOutputID: zone?.id, popAll: true ) ) ) ) if let msg = browse.message { message = msg } guard browse.action == .list else { throw LoadError.nonListResponse } logger.debug("Loading root page of \(hierarchy.rawValue)") let load = try BrowseService.LoadResponse( try await conn.request( Moo( load: .init( hierarchy: hierarchy, multiSessionKey: id.uuidString, count: UInt(UInt16.max), ) ) ) ) logger.debug("Got root page for \(hierarchy.rawValue)") rootPage = .loaded( load.list, BrowseItemGroup.groupInto(items: load.items) ) } catch { logger.error( "Unable to load root page of \(hierarchy.rawValue): \(error)" ) rootPage = .failed(error) } } } private func popPages(items: [BrowseService.Item]) { guard let hierarchy = hierarchy else { return } if let loading = self.loading { loading.cancel() } for item in items { pages[item] = nil } let level = items.count self.loading = Task { do { logger.debug("Popping \(level) levels") let browse = try BrowseService.BrowseResponse( try await conn.request( Moo( browse: .init( hierarchy: hierarchy, multiSessionKey: id.uuidString, zoneOrOutputID: zone?.id, popLevels: UInt(level) ) ) ) ) if let msg = browse.message { message = msg } switch browse.action { case .list: guard let list = browse.list else { logger.warning("Roon server returned list action without payload") return } let item = stack.last do { logger.debug("Updating the new current page after pop") let load = try BrowseService.LoadResponse( try await conn.request( Moo( load: .init( hierarchy: hierarchy, multiSessionKey: id.uuidString, level: list.level, count: UInt(UInt16.max) ) ) ) ) if let item = item { pages[item] = .loaded( load.list, BrowseItemGroup.groupInto(items: load.items) ) } else { rootPage = .loaded( load.list, BrowseItemGroup.groupInto(items: load.items) ) } } catch { logger.error("Unable to load topmost page after pop: \(error)") } default: // TODO: Handle other cases break } } catch { logger.error("Unable to pop pages: \(error)") } } } func loadPage(item: BrowseService.Item) { guard let hierarchy = hierarchy else { return } if let loading = self.loading { loading.cancel() } self.loading = Task { pages[item] = .loading let list: BrowseService.List do { logger.debug( "Moving browse cursor to \(item.title)/\(item.itemKey ?? "<nil>")/\(self.stack.count)" ) let browse = try BrowseService.BrowseResponse( try await conn.request( Moo( browse: .init( hierarchy: hierarchy, multiSessionKey: id.uuidString, itemKey: item.itemKey, zoneOrOutputID: zone?.id ) ) ) ) if let msg = browse.message { message = msg } guard browse.action == .list else { logger.info( "Only list action is supported for now: got \(browse.action.rawValue)" ) throw LoadError.nonListResponse } guard let responseList = browse.list else { logger.error( "Roon server returned list action, but list property is empty" ) throw LoadError.nonListResponse } list = responseList } catch { logger.error("Unable to browse page: \(error)") pages[item] = .failed(error) return } var itemToLoad: BrowseService.Item = item // Some items do automatically pop server stack. Clients have to guess that by // inspecting list's level and comparing that to the locally maintained stack. if list.level < _stack.count { _stack = Array(_stack[0..<Int(list.level)]) pages[item] = nil guard let lastItem = _stack.last else { logger.debug("Server-side pop to root page") return } itemToLoad = lastItem } let item = itemToLoad do { logger.debug( "Loading page at \(item.title)/\(item.itemKey ?? "<nil>")/\(self.stack.count)" ) let load = try BrowseService.LoadResponse( try await conn.request( Moo( load: .init( hierarchy: hierarchy, multiSessionKey: id.uuidString, level: list.level, count: UInt(UInt16.max) ) ) ) ) logger.debug("Got page for \(item.title)") pages[item] = .loaded( load.list, BrowseItemGroup.groupInto(items: load.items) ) } catch { logger.error("Unable to load page: \(error)") pages[item] = .failed(error) } } } func reload() { stack = stack } }
-
-
-
@@ -1,210 +0,0 @@// Copyright 2025 Shota FUJI // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 import Foundation import Network import OSLog import RoonKit import SwiftUI enum CancelError: Error { case cancelled } enum ConnectionState { case connecting case connected(Communicatable & Connectable) case waitingToReconnect case failed(any Error) } #if os(macOS) let systemName = "macOS" #else let systemName = UIDevice.current.systemName #endif @MainActor @Observable final class ConnectionDataModel { private static let reconnectionWindow: ContinuousClock.Instant.Duration = .seconds(5) private(set) var state: ConnectionState = .connecting @ObservationIgnored private var loop: Task<Void, Never>? = nil private(set) var serverID: String? let host: String let port: UInt16 private var token: String? = nil /// Initialize data model with resolved server. convenience init( server: Server, onConnect: ((_ token: String) -> Void)? = nil ) { self.init( serverID: server.id, host: server.host, port: server.port, onConnect: onConnect ) } convenience init( _ other: ConnectionDataModel, onConnect: ((_ token: String) -> Void)? = nil ) { self.init( serverID: other.serverID, host: other.host, port: other.port, token: other.token, onConnect: onConnect ) } init(cancelling: ConnectionDataModel) { self.serverID = cancelling.serverID self.host = cancelling.host self.port = cancelling.port self.token = cancelling.token self.state = .failed(CancelError.cancelled) } #if DEBUG /// To prevent I/O in Xcode Preview environment. init(failingWith: CancelError) { self.serverID = "" self.host = "" self.port = 0 self.state = .failed(failingWith) } #endif init( serverID: String? = nil, host: String, port: UInt16, token: String? = nil, onConnect: ((_ token: String) -> Void)? = nil ) { self.serverID = serverID self.host = host self.port = port self.loop = Task { [weak self] in while true { do { self?.state = .connecting let ext = RegistryService.Extension( id: "jp.pocka.plac.apple", displayName: "Plac for \(systemName)", version: "0.0.1", publisher: "Shota FUJI", email: "pockawoooh@gmail.com", requiredServices: [ TransportService.id, BrowseService.id, ImageService.id, ], token: token, ) let conn = try await Connection( id: serverID, host: host, port: port, ext: ext ) self?.state = .connected(conn) let connectionStartsAt = ContinuousClock.now let token = await conn.token self?.token = token if let token = token { onConnect?(token) } await conn.lifetime() let connectionEndsAt = ContinuousClock.now if let self = self { state = .waitingToReconnect } Logger().debug("Connection closed, reconnecting after interval") try await Task.sleep( for: Self.reconnectionWindow - (connectionEndsAt - connectionStartsAt) ) } catch { switch error { // These error happens when a target server is down. // This does not catch ETIMEDOUT; we don't want our app to // unnecessarily access network interface. Most of the time // that error code returned, is server being down or rebooting. case Connection.ConnectError.connectionNotReady( NWError.posix(.ECONNREFUSED) ), Connection.ConnectError.connectionNotReady( NWError.posix(.ECONNABORTED) ): Logger().info("Connection refused, retrying after 3 seconds") if let self = self { state = .waitingToReconnect } do { try await Task.sleep(for: .seconds(3)) continue } catch { // Abort loop on cancellation return } default: Logger().warning("Connection error: \(error)") if let self = self { state = .failed(error) } } return } } } } deinit { Logger().debug("Deinitializing ConnectionDataModel") if let loop = self.loop { loop.cancel() } } func imageURL(req: ImageService.GetRequest) -> URL? { URL(roonImage: req, host: host, port: port) } }
-
-
-
@@ -1,34 +0,0 @@// Copyright 2025 Shota FUJI // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 import Foundation import Observation import RoonKit @Observable final class ImageURLBuilderDataModel { private var host: String private var port: UInt16 init(host: String, port: UInt16) { self.host = host self.port = port } public func build(req: ImageService.GetRequest) -> URL? { URL(roonImage: req, host: host, port: port) } }
-
-
-
@@ -1,390 +0,0 @@// Copyright 2025 Shota FUJI // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 import Foundation import MediaPlayer import OSLog import RoonKit import SwiftUI @MainActor @Observable final class ZoneDataModel { @ObservationIgnored private let browsing: BrowsingDataModel? @ObservationIgnored private let imageURLBuilder: ImageURLBuilderDataModel? // Now playing feature is not very suited for apps that work as a frontend for // network audio. iOS/iPadOS aggressively suspends non foreground apps, that // closes network connection, hence playback status no longer receives updates // and playback controls would be ignored or errored out. As Roon client requires // an alive connection whole time, "wake the app on incoming packet" technique // won't work (OS will shutdown the connection, I guess.) Missing feature is better // than fundamentally broken feature. #if os(macOS) @ObservationIgnored private let commandCenter = MPRemoteCommandCenter.shared() @ObservationIgnored private var playHandler: Any? = nil @ObservationIgnored private var pauseHandler: Any? = nil @ObservationIgnored private var prevHandler: Any? = nil @ObservationIgnored private var nextHandler: Any? = nil @ObservationIgnored private var seekHandler: Any? = nil #endif let conn: Communicatable public var zone: TransportService.Zone? = nil { didSet { if !suppressSeekUpdates, let zone = zone { seek = Float(seeks[zone.id].flatMap({ $0 }) ?? 0) } if let browsing = browsing { browsing.zone = zone } updateNowPlaying() } } private(set) var zones: [TransportService.Zone] = [] { didSet { if let zoneID = zone?.id { for zone in zones { if zone.id == zoneID { self.zone = zone return } } } for zone in zones { self.zone = zone return } zone = nil } } /// Whenever this property is on, `ZoneDataModel` skips updating `seek` property. /// Use this automatic changes to `seek` property is not desirable, e.g. user dragging a seekbar. public var suppressSeekUpdates: Bool = false private var seeks: [TransportService.Zone.ID: UInt64?] = [:] { didSet { guard let zone = zone else { return } if !suppressSeekUpdates { seek = Float(seeks[zone.id].flatMap({ $0 }) ?? 0) } } } public var seek: Float = 0.0 init( conn: Communicatable, browsing: BrowsingDataModel? = nil, imageURLBuilder: ImageURLBuilderDataModel? = nil ) { self.conn = conn self.browsing = browsing self.imageURLBuilder = imageURLBuilder #if os(macOS) // MPRemoteCommandCenter does not support async functions. As there seems no way // to safely wait for async task, we simply returns success even if a request // failed. self.playHandler = commandCenter.playCommand.addTarget { [unowned self] event in guard let zone = self.zone else { return .deviceNotFound } Task { do { let res = try await self.conn.request( .init(control: .init(zoneID: zone.id, control: .play)) ) _ = try TransportService.ControlResponse.init(res) } catch { Logger().warning("Failed to send play request: \(error)") } } return .success } self.pauseHandler = commandCenter.pauseCommand.addTarget { [unowned self] event in guard let zone = self.zone else { return .deviceNotFound } Task { do { let res = try await self.conn.request( .init(control: .init(zoneID: zone.id, control: .pause)) ) _ = try TransportService.ControlResponse.init(res) } catch { Logger().warning("Failed to send pause request: \(error)") } } return .success } self.prevHandler = commandCenter.previousTrackCommand.addTarget { [unowned self] event in guard let zone = self.zone else { return .deviceNotFound } Task { do { let res = try await self.conn.request( .init(control: .init(zoneID: zone.id, control: .previous)) ) _ = try TransportService.ControlResponse.init(res) } catch { Logger().warning("Failed to send prev track request: \(error)") } } return .success } self.nextHandler = commandCenter.nextTrackCommand.addTarget { [unowned self] event in guard let zone = self.zone else { return .deviceNotFound } Task { do { let res = try await self.conn.request( .init(control: .init(zoneID: zone.id, control: .next)) ) _ = try TransportService.ControlResponse.init(res) } catch { Logger().warning("Failed to send next track request: \(error)") } } return .success } self.seekHandler = commandCenter.changePlaybackPositionCommand.addTarget { [unowned self] event in guard let zone = self.zone else { return .deviceNotFound } guard let event = event as? MPChangePlaybackPositionCommandEvent else { return .commandFailed } Task { do { let res = try await self.conn.request( .init( seek: .init( zoneID: zone.id, seconds: Int64(event.positionTime), mode: .absolute ) ) ) _ = try TransportService.SeekResponse.init(res) } catch { Logger().warning("Failed to send seek request: \(error)") } } return .success } commandCenter.bookmarkCommand.isEnabled = false commandCenter.changePlaybackRateCommand.isEnabled = false commandCenter.changeRepeatModeCommand.isEnabled = false commandCenter.changeShuffleModeCommand.isEnabled = false commandCenter.disableLanguageOptionCommand.isEnabled = false commandCenter.dislikeCommand.isEnabled = false commandCenter.enableLanguageOptionCommand.isEnabled = false commandCenter.likeCommand.isEnabled = false commandCenter.ratingCommand.isEnabled = false #endif } deinit { #if os(macOS) if let playHandler = playHandler { commandCenter.playCommand.removeTarget(playHandler) } if let pauseHandler = pauseHandler { commandCenter.pauseCommand.removeTarget(pauseHandler) } if let prevHandler = prevHandler { commandCenter.previousTrackCommand.removeTarget(prevHandler) } if let nextHandler = nextHandler { commandCenter.nextTrackCommand.removeTarget(nextHandler) } if let seekHandler = seekHandler { commandCenter.changePlaybackPositionCommand.removeTarget(seekHandler) } #endif } func watchChanges() async throws { let msg = try await conn.request( Moo(subscribeZoneChange: .init(subscriptionID: UUID().uuidString)) ) let body = try TransportService.SubscribeZoneChangesResponse(msg) zones = body.zones for zone in zones { seeks[zone.id] = zone.nowPlaying?.seekPosition } updateNowPlaying() for try await message in await conn.messages.compactMap({ TransportService.ZoneChangeEvent($0) }) { for added in message.addedZones { putZone(zone: added) } for changed in message.changedZones { putZone(zone: changed) } for removedID in message.removedZoneIDs { removeZone(zoneID: removedID) } for seekChange in message.seekChanges { seeks[seekChange.zoneID] = seekChange.seekPosition } updateNowPlaying() } } private func updateNowPlaying() { #if os(macOS) let center = MPNowPlayingInfoCenter.default() var nowPlayingInfo = [String: Any]() switch self.zone?.state { case .playing: center.playbackState = .playing case .paused: center.playbackState = .paused default: center.playbackState = .stopped } commandCenter.pauseCommand.isEnabled = zone?.isPauseAllowed ?? false commandCenter.playCommand.isEnabled = zone?.isPauseAllowed ?? false commandCenter.previousTrackCommand.isEnabled = zone?.isPreviousAllowed ?? false commandCenter.nextTrackCommand.isEnabled = zone?.isNextAllowed ?? false commandCenter.changePlaybackPositionCommand.isEnabled = zone?.isSeekAllowed ?? false defer { center.nowPlayingInfo = nowPlayingInfo } guard let zone = self.zone, let nowPlaying = zone.nowPlaying else { return } if let secondLine = nowPlaying.doubleLine.line2 { nowPlayingInfo[MPMediaItemPropertyArtist] = secondLine } if let artwork = nowPlaying.imageKey, let imageURLBuilder = self.imageURLBuilder { let image = MPMediaItemArtwork( boundsSize: .init(width: 256, height: 256), requestHandler: { size in let url = imageURLBuilder.build( req: ImageService.GetRequest.init( key: artwork, format: .png, scale: .fit, width: UInt(size.width), height: UInt(size.height) ) ) guard let url = url, let data = try? Data(contentsOf: url), let image = NSImage(data: data) else { return NSImage(size: size) } return image } ) nowPlayingInfo[MPMediaItemPropertyArtwork] = image } nowPlayingInfo[MPMediaItemPropertyMediaType] = MPMediaType.music.rawValue if let duration = nowPlaying.length { nowPlayingInfo[MPMediaItemPropertyPlaybackDuration] = duration } nowPlayingInfo[MPMediaItemPropertyTitle] = nowPlaying.doubleLine.line1 if let seek = seeks[zone.id] ?? nowPlaying.seekPosition { nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = seek } #endif } private func putZone(zone: TransportService.Zone) { if let index = zones.firstIndex(where: { z in z.id == zone.id }) { zones[index] = zone } else { zones.append(zone) } seeks[zone.id] = zone.nowPlaying?.seekPosition } private func removeZone(zoneID: TransportService.Zone.ID) { if let index = zones.firstIndex(where: { z in z.id == zoneID }) { zones.remove(at: index) } seeks[zoneID] = nil } }
-
-
macos/Plac/Localizable.xcstrings (deleted)
-
@@ -1,1012 +0,0 @@{ "sourceLanguage" : "en", "strings" : { "AppTitle" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Plac" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "Plac" } } } }, "Browser.Error.Description" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "An error occurred when loading the page." } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "ページの読込中にエラーが発生しました。" } } } }, "Browser.Error.Heading" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Failed to load" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "読み込み失敗" } } } }, "Browser.Error.Retry" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Retry" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "再読み込み" } } } }, "ConnectedScene.Category.Albums" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Albums" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "アルバム" } } } }, "ConnectedScene.Category.Artists" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Artists" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "アーティスト" } } } }, "ConnectedScene.Category.Composers" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Composers" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "作曲者" } } } }, "ConnectedScene.Category.Explore" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Explore" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "ホーム" } } } }, "ConnectedScene.Category.Genres" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Genres" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "ジャンル" } } } }, "ConnectedScene.Category.InternetRadio" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Internet Radios" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "インターネットラジオ" } } } }, "ConnectedScene.Category.Playlists" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Playlists" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "プレイリスト" } } } }, "ConnectedScene.Category.Search" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Search" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "検索" } } } }, "ConnectedScene.Category.Settings" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Settings" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "設定" } } } }, "ConnectedScene.ServerMessage.Dismiss" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Dismiss" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "閉じる" } } } }, "ConnectedScene.ServerMessage.Title" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Message from Roon server" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "Roon サーバからのメッセージ" } } } }, "ConnectionScreen.Cancelled.Description" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Cancelled connecting to Roon server." } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "ユーザによって Roon サーバへの接続がキャンセルされました。" } } } }, "ConnectionScreen.Cancelled.Heading" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Connection cancelled" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "接続キャンセル" } } } }, "ConnectionScreen.Disconnect" : { "comment" : "Button label for aborting connection process.", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Close" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "閉じる" } } } }, "ConnectionScreen.Error.Description" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Failed to connect due to an error." } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "エラーが発生したため Roon サーバへ接続できませんでした。" } } } }, "ConnectionScreen.Error.Heading" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Failed to connect" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "接続エラー" } } } }, "ConnectionScreen.Loading.Cancel" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Cancel" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "キャンセル" } } } }, "ConnectionScreen.Loading.Label" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Connecting to Roon Server. Make sure you granted access at Settings > Extension page in official Roon client." } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "Roon サーバに接続しています。 Roon 公式アプリケーションの “設定 > 拡張” ページで Plac を承認してください。" } } } }, "ConnectionScreen.NotFound.Description" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Server %@ does not found on network." } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "Roon サーバ (ID: %@) がネットワーク上に見つかりませんでした。" } } } }, "ConnectionScreen.NotFound.Heading" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Server not found" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "見つかりません" } } } }, "ConnectionScreen.Reconnect" : { "comment" : "Button label for connecting to Roon server from non-connected state.", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Reconnect" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "再接続" } } } }, "Menu.Disconnect" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Disconnect" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "サーバから切断" } } } }, "PlaybackBar.Next" : { "comment" : "A label for a next button.", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Next" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "次へ" } } } }, "PlaybackBar.Pause" : { "comment" : "A label for a pause button.", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Pause" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "一時停止" } } } }, "PlaybackBar.Play" : { "comment" : "A label for a play button.", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Play" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "再生" } } } }, "PlaybackBar.Previous" : { "comment" : "A label for a previous button.", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Previous" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "前へ" } } } }, "PlaybackBar.Seekbar" : { "comment" : "A label for seekbar.", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Playback seekbar" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "再生バー" } } } }, "PlaybackBar.ZonePanel.Close" : { "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", "value" : "Done" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "閉じる" } } } }, "PlaybackBar.ZonePanel.Label" : { "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", "value" : "Zone" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "ゾーン" } } } }, "ServerAddressForm.Connect" : { "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", "value" : "Connect" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "接続する" } } } }, "ServerAddressForm.Description" : { "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", "value" : "By tapping Connect, Plac will open a HTTP connection to %@" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "%@ に対して接続を試みます。" } } } }, "ServerAddressForm.Host.Label" : { "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", "value" : "IPv4 address" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "IPアドレス (v4)" } } } }, "ServerAddressForm.InvalidHost.Close" : { "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", "value" : "Close" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "閉じる" } } } }, "ServerAddressForm.InvalidHost.Description" : { "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", "value" : "\"%@\" is not a valid IPv4 address." } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "“%@” は有効な IP アドレスではありません。" } } } }, "ServerAddressForm.InvalidHost.Title" : { "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", "value" : "Fill valid IPv4 address" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "有効な IP アドレスを入力してください。" } } } }, "ServerAddressForm.Port.Label" : { "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", "value" : "HTTP port" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "HTTP ポート" } } } }, "ServerAddressForm.SectionName" : { "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", "value" : "Roon server" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "Roon サーバ" } } } }, "ServerAddressForm.Title" : { "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", "value" : "Connect to server" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "サーバへ接続" } } } }, "ServerDiscovery.Empty.Description" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "No Roon server found on local network." } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "ネットワーク上に Roon サーバが見つかりませんでした。" } } } }, "ServerDiscovery.Empty.Heading" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "No server found" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "見つかりません" } } } }, "ServerDiscovery.Error.Description" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "An error occurred during a scan." } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "検索中にエラーが発生しました。" } } } }, "ServerDiscovery.Error.Heading" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Scan failed" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "検索失敗" } } } }, "ServerDiscovery.Host" : { "comment" : "A label for server host field.", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "IP Address" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "アドレス" } } } }, "ServerDiscovery.Loading.Description" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Scanning Roon servers on local network." } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "ネットワーク上にある Roon サーバを検索しています。" } } } }, "ServerDiscovery.Loading.Heading" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Scanning" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "検索中" } } } }, "ServerDiscovery.NetworkError.Description" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Failed to scan Roon servers due to network error." } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "検索中にネットワークの問題が発生しました。" } } } }, "ServerDiscovery.NetworkError.Heading" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Scan failed" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "検索失敗" } } } }, "ServerDiscovery.Open" : { "comment" : "Label for a button that starts a connection.", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Open" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "開く" } } } }, "ServerDiscovery.Placeholder" : { "comment" : "Placeholder text for server details view.", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Select server from list" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "サーバを選んでください" } } } }, "ServerDiscovery.Refresh" : { "comment" : "Icon label for refresh button.", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Refresh" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "更新" } } } }, "ServerDiscovery.RefreshHelp" : { "comment" : "Help tooltip text for refresh button.", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Refresh server list" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "リストを更新する" } } } }, "ServerDiscovery.Title" : { "comment" : "Title of server discovery scene. Not visible on macOS.", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Servers" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "サーバ一覧" } } } }, "ServerDiscovery.Version" : { "comment" : "A label for server version field.", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Version" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "バージョン" } } } }, "ZoneOptionsPanel.NoVolumeControl" : { "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", "value" : "This output does not support volume control." } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "この出力機器は Roon 上からの音量制御ができません。" } } } }, "ZoneOptionsPanel.StepVolumeLabel" : { "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", "value" : "Volume Control" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "音量調整" } } } }, "ZoneOptionsPanel.VolumeSlider" : { "comment" : "A label for volume slider.", "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", "value" : "Volume" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "音量" } } } }, "ZoneOptionsPanel.ZonePicker" : { "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", "value" : "Zone" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "ゾーン" } } } } }, "version" : "1.0" }
-
-
macos/Plac/Localizable.xcstrings.license (deleted)
-
@@ -1,15 +0,0 @@Copyright 2025 Shota FUJI Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. SPDX-License-Identifier: Apache-2.0
-
-
macos/Plac/Plac.entitlements (deleted)
-
@@ -1,14 +0,0 @@<?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.files.user-selected.read-only</key> <true/> <key>com.apple.security.network.client</key> <true/> <key>com.apple.security.network.server</key> <true/> </dict> </plist>
-
-
macos/Plac/PlacApp.swift (deleted)
-
@@ -1,182 +0,0 @@// Copyright 2025 Shota FUJI // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 import RoonKit import SwiftUI /// SwitUI's Commands are not designed to pass State or such via initializer. /// In doing that so, closures capture the environment thus leaks passed States. /// As `FocusedValue` cannot mutate the value itself, this class wraps the model /// object and mimics state manipulation. @MainActor @Observable private final class ConnectionDataModelWrapper { var model: ConnectionDataModel? init(_ model: ConnectionDataModel? = nil) { self.model = model } } private struct ConnectionCommands: Commands { @FocusedValue(ConnectionDataModelWrapper.self) private var model: ConnectionDataModelWrapper? var body: some Commands { CommandGroup(after: .appInfo) { Button { model?.model = nil } label: { Label { Text(String(localized: "Menu.Disconnect", defaultValue: "Disconnect")) } icon: { Image(systemName: "powercode") } } .disabled(model?.model == nil) } } } @main struct PlacApp: App { @State private var model: ConnectionDataModelWrapper = ConnectionDataModelWrapper(Self.storedConnection()) private static func storedConnection() -> ConnectionDataModel? { // Xcode Previews feature compiles and *runs* whole application. // Because Plac saves and restores from UserDefaults, the background instance // connects to Roon Server using the saved ID/host/port/token. The worst // part is running the app does not immediately terminate the background // instance--so there will be two app connecting to same destination using // same token. Roon Server drops the first connection, then the background // instance retries, then running instance dropped, then running instance // retries, so on... This results in endless connection loop. // // Below "guard" prevents that shitty "feature" from kicking in. An additional // bonus is, Preview loading performance is much better with this guard. #if DEBUG if ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1" { return ConnectionDataModel(failingWith: .cancelled) } #endif let port = UserDefaults.standard.integer(forKey: StorageKeys.serverPort) let serverID = UserDefaults.standard.string(forKey: StorageKeys.serverID) guard let host = UserDefaults.standard.string(forKey: StorageKeys.serverHost), port > 0 else { return nil } return ConnectionDataModel( serverID: serverID, host: host, port: UInt16(port), token: UserDefaults.standard.string(forKey: StorageKeys.extensionToken), onConnect: { token in Self.saveToken(token) } ) } private static func saveConnection( serverID: String? = nil, host: String? = nil, port: UInt16? = nil ) { UserDefaults.standard.set(serverID, forKey: StorageKeys.serverID) UserDefaults.standard.set(host, forKey: StorageKeys.serverHost) UserDefaults.standard.set(Int(port ?? 0), forKey: StorageKeys.serverPort) } private static func saveToken(_ token: String? = nil) { UserDefaults.standard.set(token, forKey: StorageKeys.extensionToken) } var body: some Scene { WindowGroup( Text(String(localized: "AppTitle", defaultValue: "Plac")), id: "main-window" ) { if let model = model.model { ConnectionScreen( model: model, onDisconnect: { self.model.model = nil Self.saveToken() Self.saveConnection() }, onReconnect: { self.model.model = ConnectionDataModel( model, onConnect: { token in Self.saveToken(token) } ) }, onCancel: { self.model.model = ConnectionDataModel(cancelling: model) } ) .focusedSceneValue(self.model) } else { #if os(macOS) ServerDiscoveryScreen() .onConnect { server in self.model.model = ConnectionDataModel( serverID: server.id, host: server.host, port: server.port, onConnect: { token in Self.saveToken(token) } ) Self.saveToken() Self.saveConnection( serverID: server.id, host: server.host, port: server.port ) } #else ServerAddressForm() .onConnect { host, port in self.model.model = ConnectionDataModel( host: host, port: port, onConnect: { token in Self.saveToken(token) } ) Self.saveToken() Self.saveConnection(host: host, port: port) } #endif } } .commands { ConnectionCommands() } .defaultSize(width: 800, height: 600) } }
-
-
macos/Plac/StorageKeys.swift (deleted)
-
@@ -1,22 +0,0 @@// Copyright 2025 Shota FUJI // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 struct StorageKeys { static let serverID = "Plac.serverID" static let serverHost = "Plac.serverHost" static let serverPort = "Plac.serverPort" static let extensionToken = "Plac.token" }
-
-
macos/Plac/Views/Artwork.swift (deleted)
-
@@ -1,53 +0,0 @@// Copyright 2025 Shota FUJI // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 import RoonKit import SwiftUI struct Artwork: View { @Environment(ImageURLBuilderDataModel.self) var builder let imageKey: String let width: CGFloat let height: CGFloat private var url: URL? { builder.build( req: .init( key: imageKey, format: .png, scale: .fit, width: UInt(width), height: UInt(height) ) ) } var body: some View { AsyncImage(url: url) { phase in if let image = phase.image { image.resizable() } else if phase.error != nil { Color.gray } else { ProgressView() .scaleEffect(0.5) } } .id(url) .clipShape(.rect(cornerRadius: 2)) } }
-
-
macos/Plac/Views/Browser.swift (deleted)
-
@@ -1,166 +0,0 @@// Copyright 2025 Shota FUJI // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 import OSLog import RoonKit import SwiftUI struct Browser: View { private let logger = Logger() private let model: BrowsingDataModel private let item: BrowseService.Item? private var page: BrowsePage? { if let item = item { model.pages[item] } else { model.rootPage } } var body: some View { HStack { switch page { case .none, .some(.loading): ProgressView() .frame(maxWidth: .infinity, maxHeight: .infinity) .background(.background) case .some(.failed(_)): ContentUnavailableView { Label { Text( String( localized: "Browser.Error.Heading", defaultValue: "Failed to load" ) ) } icon: { Image(systemName: "exclamationmark.triangle") } } description: { Text( String( localized: "Browser.Error.Description", defaultValue: "An error occurred when loading the page." ) ) } actions: { Button { model.reload() } label: { Text( String(localized: "Browser.Error.Retry", defaultValue: "Retry") ) } } case .some(.loaded(let list, let groups)): List { ForEach(groups) { group in Section { ForEach(group.items, id: \.itemKey) { item in switch item.hint { case .list, .actionList: NavigationLink(value: item) { Row(item) } case .action: Button { model.loadPage(item: item) } label: { Row(item) .frame(maxWidth: .infinity, alignment: .leading) if model.pages[item]?.isLoading() ?? false { ProgressView() // Row height in macOS is way shorter than other platforms. #if os(macOS) .controlSize(.small) #endif } } .buttonStyle(.borderless) // ".borderless" on macOS somehow renders in gray foreground color, // which looks like the button is disabled. #if os(macOS) .foregroundStyle(.foreground) #endif .disabled(model.pages[item]?.isLoading() ?? false) default: Row(item) } } } header: { if let title = group.title { Text(title) } else { EmptyView() } } } } .navigationTitle(list.title) #if os(macOS) .navigationSubtitle(list.correctedSubtitle ?? "") #endif } } // As the page title can't be available until the data is loaded, // the newly created navigation stack fallbacks to the application // name ("Plac".) This causes <previous title> -> "Plac" -> <title> // sequence in a very short period, which results in glitchy-looking // title flash. To prevent this, the stack itself sets empty title // when an item is not available (= root page of a hierarchy) so the // title "blinks" instead of "rapidly changes." .navigationTitle(item?.title ?? "") } init(model: BrowsingDataModel, item: BrowseService.Item? = nil) { self.model = model self.item = item } } private struct Row: View { private let item: BrowseService.Item fileprivate init(_ item: BrowseService.Item) { self.item = item } var body: some View { HStack { if let imageKey = item.imageKey { Artwork(imageKey: imageKey, width: 64, height: 64) .frame(width: 32, height: 32) } VStack(alignment: .leading) { Text(item.title) .font(.headline) .lineLimit(1) if let subtitle = item.correctedSubtitle { Text(subtitle) .font(.subheadline) .lineLimit(1) } } .frame(maxWidth: .infinity, alignment: .leading) } .padding(.vertical, 2) } }
-
-
macos/Plac/Views/ConnectedScreen.swift (deleted)
-
@@ -1,470 +0,0 @@// Copyright 2025 Shota FUJI // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 import OSLog import RoonKit import SwiftUI struct ConnectedScreen: View { private let logger = Logger() @Environment(ImageURLBuilderDataModel.self) var builder @State private var model: ZoneDataModel @State private var browsing: BrowsingDataModel @State private var hierarchies = [ BrowseService.Hierarchy.browse, BrowseService.Hierarchy.playlists, BrowseService.Hierarchy.albums, BrowseService.Hierarchy.artists, BrowseService.Hierarchy.composers, BrowseService.Hierarchy.genres, BrowseService.Hierarchy.settings, BrowseService.Hierarchy.internetRadio, ] init(conn: Communicatable & Connectable, host: String, port: UInt16) { let browsing = BrowsingDataModel(conn: conn) self.browsing = browsing model = ZoneDataModel( conn: conn, browsing: browsing, imageURLBuilder: ImageURLBuilderDataModel(host: host, port: port) ) } private let actionQueue = DispatchQueue( label: "plac.ConnectedView.actionQueue" ) var body: some View { let shouldDisplayMessage = Binding { return browsing.message != nil } set: { _ in browsing.message = nil } VStack(spacing: 0) { NavigationSplitView { List(hierarchies, id: \.self, selection: $browsing.hierarchy) { hierarchy in switch hierarchy { case .browse: Text( String( localized: "ConnectedScene.Category.Explore", defaultValue: "Explore" ) ) case .playlists: Text( String( localized: "ConnectedScene.Category.Playlists", defaultValue: "Playlists" ) ) case .settings: Text( String( localized: "ConnectedScene.Category.Settings", defaultValue: "Settings" ) ) case .albums: Text( String( localized: "ConnectedScene.Category.Albums", defaultValue: "Albums" ) ) case .artists: Text( String( localized: "ConnectedScene.Category.Artists", defaultValue: "Artists" ) ) case .genres: Text( String( localized: "ConnectedScene.Category.Genres", defaultValue: "Genres" ) ) case .composers: Text( String( localized: "ConnectedScene.Category.Composers", defaultValue: "Composers" ) ) case .search: Text( String( localized: "ConnectedScene.Category.Search", defaultValue: "Search" ) ) case .internetRadio: Text( String( localized: "ConnectedScene.Category.InternetRadio", defaultValue: "Internet Radios" ) ) } } } detail: { if browsing.hierarchy != nil { NavigationStack(path: $browsing.stack) { Browser(model: browsing) .navigationDestination(for: BrowseService.Item.self) { item in Browser(model: browsing, item: item) } } } } .alert( Text( String( localized: "ConnectedScene.ServerMessage.Title", defaultValue: "Message from Roon server" ) ), isPresented: shouldDisplayMessage, presenting: browsing.message ) { _ in Button( String( localized: "ConnectedScene.ServerMessage.Dismiss", defaultValue: "Dismiss" ) ) { browsing.message = nil } } message: { message in Text(message) } Divider() PlaybackBar() .frame(maxWidth: .infinity) .environment(model) } .task { // macOS, iPadOS and visionOS renders `NavigationSplitView` with columns whereas // iOS renders stacked views. In columns layout, having no default selection or // placeholder content ("select a category") is not user-friendly. // In iPad, the situation got worse; it hides sidebar on startup in portrait // orientation, so user will see blank browsing area. Placeholder won't help // this case at all. #if os(macOS) let shouldHaveDefaultHierarchy = true #else let shouldHaveDefaultHierarchy = UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.userInterfaceIdiom == .vision #endif if shouldHaveDefaultHierarchy { browsing.hierarchy = .browse } } .task { do { try await model.watchChanges() } catch { logger.error("Failed to watch zone changes: \(error)") } } } } // MARK: - Preview private enum MockError: Error { case notImplementedInMock } private struct MockPage { let browse: String let load: String func browseMsg() throws -> Moo { let bytes = browse.data(using: .utf8)! return Moo( verb: "COMPLETE", service: "Success", headers: [ "Content-Type": "application/json", "Content-Length": String(bytes.count, radix: 10), ], body: bytes, ) } func loadMsg() throws -> Moo { let bytes = load.data(using: .utf8)! return Moo( verb: "COMPLETE", service: "Success", headers: [ "Content-Type": "application/json", "Content-Length": String(bytes.count, radix: 10), ], body: bytes, ) } } private actor MockServer { var stack: [MockPage] = [] } extension MockServer: Communicatable { var root: MockPage { .init( browse: """ { "action": "list", "list": { "title": "Mock Page", "subtitle": "Various responses", "level": 0 } } """, load: """ { "list": { "title": "Mock Page", "subtitle": "Various responses", "level": 0 }, "items": [ { "title": "List", "item_key": "mock-list", "hint": "list" }, { "title": "Slow List", "item_key": "mock-slow-list", "hint": "list" }, { "title": "Action List", "item_key": "mock-action_list", "hint": "action_list" }, { "title": "Action", "item_key": "mock-action", "hint": "action" }, { "title": "Header", "item_key": "mock-header", "hint": "header" }, { "title": "Unknown", "item_key": "mock-unknown" } ] } """ ) } var rootAction: MockPage { .init( browse: """ { "action": "list", "list": { "title": "Mock Page", "subtitle": "Various responses", "level": 0 }, "message": "You performed an action." } """, load: root.load ) } var list: MockPage { .init( browse: """ { "action": "list", "list": { "title": "List", "level": 1 } } """, load: """ { "list": { "title": "List", "subtitle": "Various responses", "level": 1 }, "items": [ { "title": "Action", "item_key": "mock-list-action", "hint": "action" }, { "title": "Item 1", "subtitle": "This is 1st item", "item_key": "mock-list-item1", "hint": "action_list" }, { "title": "Item 2", "subtitle": "This is 2nd item", "item_key": "mock-list-item2", "hint": "action_list" }, { "title": "Item 3", "subtitle": "This is 3rd item", "item_key": "mock-list-item3", "hint": "action_list" }, { "title": "Item 4", "subtitle": "This is 4th item", "item_key": "mock-list-item4", "hint": "action_list" } ] } """ ) } var messages: AsyncStream<Moo> { AsyncStream { stream in stream.finish() } } enum RequestError: Error { case noBody case notImplemented } func request(_ msg: consuming Moo) async throws -> Moo { switch msg.service { case "\(BrowseService.id)/browse": guard let body = msg.body, let data = body.data(using: .utf8) else { throw RequestError.noBody } let req = try JSONDecoder().decode( BrowseService.BrowseRequest.self, from: data ) guard req.hierarchy == .browse else { return Moo(verb: "COMPLETE", service: "MockNotImplemented") } if req.popAll == .some(true) { stack = [] } else if let pop = req.popLevels { stack = Array(stack[0...(stack.count - Int(pop))]) } else if let itemKey = req.itemKey { switch itemKey { case "mock-list": stack.append(list) case "mock-slow-list": try await Task.sleep(for: .milliseconds(500)) stack.append(list) case "mock-action": try await Task.sleep(for: .milliseconds(300)) print("Got action: \(itemKey)") stack = [] return try rootAction.browseMsg() case "mock-list-action": try await Task.sleep(for: .milliseconds(300)) print("Got action: \(itemKey)") stack.removeLast() default: return Moo(verb: "COMPLETE", service: "UnknownItemKey") } } let page = stack.last ?? root return try page.browseMsg() case "\(BrowseService.id)/load": guard let body = msg.body, let data = body.data(using: .utf8) else { throw RequestError.noBody } let req = try JSONDecoder().decode( BrowseService.LoadRequest.self, from: data ) guard req.hierarchy == .browse else { return Moo(verb: "COMPLETE", service: "MockNotImplemented") } let page = stack.last ?? root return try page.loadMsg() default: throw RequestError.notImplemented } } func request(_ msg: consuming Moo, timeout: ContinuousClock.Instant.Duration) async throws -> Moo { try await request(msg) } func send(_ msg: consuming Moo) async throws {} } extension MockServer: Connectable { var serverID: String? { "mock-server" } var host: String { "192.0.2.1" } var port: UInt16 { 9003 } func connect() async throws {} func disconnect() {} func lifetime() async { await withCheckedContinuation { (_: CheckedContinuation<Void, Never>) in } } } #Preview { ConnectedScreen(conn: MockServer(), host: "localhost", port: 8080) }
-
-
macos/Plac/Views/ConnectionScreen.swift (deleted)
-
@@ -1,205 +0,0 @@// Copyright 2025 Shota FUJI // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 import RoonKit import SwiftUI struct ConnectionScreen: View { private let conn: ConnectionDataModel private var onDisconnect: (() -> Void) private var onReconnect: (() -> Void) private var onCancel: (() -> Void) init( model: ConnectionDataModel, onDisconnect: (@escaping () -> Void), onReconnect: (@escaping () -> Void), onCancel: (@escaping () -> Void) ) { self.conn = model self.onDisconnect = onDisconnect self.onReconnect = onReconnect self.onCancel = onCancel } var body: some View { VStack { switch conn.state { case .connecting, .waitingToReconnect: Loading(onCancel: { onCancel() }) case .connected(let conn): ConnectedScreen(conn: conn, host: self.conn.host, port: self.conn.port) .environment( ImageURLBuilderDataModel(host: self.conn.host, port: self.conn.port) ) case .failed(RoonKit.ServerLookupError.notFound): ContentUnavailableView { Label { Text( String( localized: "ConnectionScreen.NotFound.Heading", defaultValue: "Server not found" ) ) } icon: { Image(systemName: "exclamationmark.magnifyingglass") } } description: { Text( String( localized: "ConnectionScreen.NotFound.Description", defaultValue: "Server \(self.conn.serverID ?? "\(self.conn.host):\(self.conn.port)") does not found on network." ) ) } actions: { Button(role: .cancel) { onDisconnect() } label: { Text( String( localized: "ConnectionScreen.Disconnect", defaultValue: "Close", comment: "Button label for aborting connection process." ) ) } Button { onReconnect() } label: { Text( String( localized: "ConnectionScreen.Reconnect", defaultValue: "Reconnect", comment: "Button label for connecting to Roon server from non-connected state." ) ) } } case .failed(CancelError.cancelled): ContentUnavailableView { Label { Text( String( localized: "ConnectionScreen.Cancelled.Heading", defaultValue: "Connection cancelled" ) ) } icon: { Image(systemName: "exclamationmark.magnifyingglass") } } description: { Text( String( localized: "ConnectionScreen.Cancelled.Description", defaultValue: "Cancelled connecting to Roon server." ) ) } actions: { Button(role: .cancel) { onDisconnect() } label: { Text("ConnectionScreen.Disconnect") } Button { onReconnect() } label: { Text("ConnectionScreen.Reconnect") } } case .failed(_): ContentUnavailableView { Label { Text( String( localized: "ConnectionScreen.Error.Heading", defaultValue: "Failed to connect" ) ) } icon: { Image(systemName: "exclamationmark.magnifyingglass") } } description: { Text( String( localized: "ConnectionScreen.Error.Description", defaultValue: "Failed to connect due to an error." ) ) } actions: { Button(role: .cancel) { onDisconnect() } label: { Text("ConnectionScreen.Disconnect") } Button { onReconnect() } label: { Text("ConnectionScreen.Reconnect") } } } } } } private struct Loading: View { private var onCancel: (() -> Void)? init(onCancel: (() -> Void)? = nil) { self.onCancel = onCancel } var body: some View { VStack(spacing: 8) { ProgressView { Text( String( localized: "ConnectionScreen.Loading.Label", defaultValue: "Connecting to Roon Server. Make sure you granted access at Settings > Extension page in official Roon client." ) ) .multilineTextAlignment(.center) } Button(role: .cancel) { onCancel?() } label: { Text( String( localized: "ConnectionScreen.Loading.Cancel", defaultValue: "Cancel" ) ) } } } } // MARK: - Preview #Preview("Loading") { Loading(onCancel: { print("cancel") }) }
-
-
macos/Plac/Views/PlaybackBar.swift (deleted)
-
@@ -1,455 +0,0 @@// Copyright 2025 Shota FUJI // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 import OSLog import RoonKit import SwiftUI struct PlaybackBar: View { @Environment(ZoneDataModel.self) var model: ZoneDataModel @State private var isPerformingAction = false @State private var isOutputPanelVisible = false private var zone: TransportService.Zone? { model.zone } var body: some View { @Bindable var model = model VStack(alignment: .leading, spacing: 14) { HStack(spacing: 8) { if let imageKey = zone?.nowPlaying?.imageKey { Artwork(imageKey: imageKey, width: 96, height: 96) .frame(width: 48, height: 48) } VStack(alignment: .leading, spacing: 2) { let line1 = zone?.nowPlaying?.doubleLine.line1 ?? " " let line2 = zone?.nowPlaying?.doubleLine.line2 ?? " " Text(line1) .font(.headline) .lineLimit(1) Text(line2) .font(.subheadline) .lineLimit(1) } .frame(maxWidth: .infinity, alignment: .leading) } HStack(spacing: 20) { HStack { Button { Task { await performAction(.previous) } } label: { Label { Text( String( localized: "PlaybackBar.Previous", defaultValue: "Previous", comment: "A label for a previous button." ) ) } icon: { Image(systemName: "backward.end.fill") } } .font(.title3) .disabled(isPerformingAction) .disabled(!(zone?.isPreviousAllowed ?? false)) ZStack { Button { Task { await performAction(.pause) } } label: { Label { Text( String( localized: "PlaybackBar.Pause", defaultValue: "Pause", comment: "A label for a pause button." ) ) } icon: { Image(systemName: "pause.fill") } } .font(.title) .disabled(isPerformingAction) .disabled(!(zone?.isPauseAllowed ?? false)) .opacity(zone?.state != .playing ? 0 : 1) Button { Task { await performAction(.play) } } label: { Label { Text( String( localized: "PlaybackBar.Play", defaultValue: "Play", comment: "A label for a play button." ) ) } icon: { Image(systemName: "play.fill") } } .font(.title) .disabled(isPerformingAction) .disabled(!(zone?.isPlayAllowed ?? false)) .opacity(zone?.state == .playing ? 0 : 1) } Button { Task { await performAction(.next) } } label: { Label { Text( String( localized: "PlaybackBar.Next", defaultValue: "Next", comment: "A label for a next button." ) ) } icon: { Image(systemName: "forward.end.fill") } } .font(.title3) .disabled(isPerformingAction) .disabled(!(zone?.isNextAllowed ?? false)) } .labelStyle(.iconOnly) .buttonStyle(.borderless) if let length = model.zone?.nowPlaying?.length, length > 0, let zone = zone { // Adding step parameter results in messy and broken-look UI on macOS. // The tick marks might work for less ticks (up to 10,) but it's completely // broken on this kind of usage; they are too dense to the point it renders // like visual glitch, and some ticks are skipped. I'd call it broken. // Fortunately, in this usecase, eliminating `step: 1.0` is acceptable because // we're casting to `Int64` anyway. So, even when SwiftUI uses steps less than // 1, fractional part will be ignored and end experience will be similar to // `step: 1.0`. Slider( value: $model.seek, in: 0.0...Float(length), onEditingChanged: { editing in if editing { model.suppressSeekUpdates = true } else { Task { do { let msg = try await model.conn.request( try Moo( seek: .init(zoneID: zone.id, seconds: Int64(model.seek)) ), timeout: .seconds(2) ) _ = try TransportService.SeekResponse(msg) } catch { Logger().warning("Failed to seek: \(error)") } // If we resume accepting seek change events before seek request, // there is a chance incoming seek change "resets" seekbar position // then seek request updates seek then an event after that moves // seekbar position. From user's perspective, this is "seekbar // jumps to previous position then jumps again to the dragged // position". That's not good UX. model.suppressSeekUpdates = false } } } ) { Text( String( localized: "PlaybackBar.Seekbar", defaultValue: "Playback seekbar", comment: "A label for seekbar." ) ) } .labelsHidden() .disabled(!(zone.isSeekAllowed ?? false)) .frame(maxWidth: .infinity) } else { Spacer() } Button { isOutputPanelVisible = true } label: { Label { Text( String( localized: "PlaybackBar.ZonePanel.Label", defaultValue: "Zone" ) ) } icon: { Image(systemName: "hifispeaker") } .labelStyle(.iconOnly) } .help(Text("PlaybackBar.ZonePanel.Label")) .sheet(isPresented: $isOutputPanelVisible) { NavigationStack { ZoneOptionsPanel(model: model) .toolbar { ToolbarItem(placement: .confirmationAction) { Button { isOutputPanelVisible = false } label: { Text( String( localized: "PlaybackBar.ZonePanel.Close", defaultValue: "Done" ) ) } } } } } } } .padding([.horizontal], 12) .padding([.vertical], 10) } private func performAction(_ action: TransportService.ControlAction) async { guard let zone = zone else { return } isPerformingAction = true do { let msg = try await model.conn.request( try Moo(control: .init(zoneID: zone.id, control: action)), timeout: .seconds(5) ) _ = try TransportService.ControlResponse(msg) } catch { Logger().warning("Failed to send control message: \(error)") } isPerformingAction = false } } // MARK: - Previews private actor MockServer: Communicatable { var position: Int64 = 0 var length: Int64 = 120 var messages: AsyncStream<Moo> { AsyncStream { stream in Task { do { while true { try Task.checkCancellation() try await Task.sleep(for: .seconds(1)) position += 1 if position > length { position = 0 } let body = """ { "zones_seek_changed": [ { "zone_id": "0-foo", "seek_position": \(String(position, radix: 10)) } ] } """ stream.yield( Moo( verb: "CONTINUE", service: "idk", headers: [ "Content-Type": "application/json", "Content-Length": String(body.utf8.count, radix: 10), ], body: body.data(using: .utf8)! ) ) } } catch { stream.finish() } } } } func request(_ msg: consuming Moo) async throws -> Moo { try await request(msg: msg, timeout: nil) } func request(_ msg: consuming Moo, timeout: ContinuousClock.Instant.Duration) async throws -> Moo { try await request(msg: msg, timeout: timeout) } fileprivate enum MockRequestError: Error { case unsupportedMessage case invalidRequest } private func request( msg: Moo, timeout: ContinuousClock.Instant.Duration? = nil ) async throws -> Moo { switch msg.service { case "\(TransportService.id)/subscribe_zones": let body = """ { "zones": [ { "zone_id": "0-foo", "display_name": "Foo", "outputs": [ { "output_id": "foo-out1", "display_name": "Foo Output #1", "volume": { "type": "number", "min": 0, "max": 100, "step": 1, "value": 50, "is_muted": false } }, { "output_id": "foo-out2", "display_name": "Foo Output #2", "volume": { "type": "incremental" } }, { "output_id": "foo-out3", "display_name": "Foo Output #3", "volume": { "type": "db", "min": 0, "max": 85, "step": 0.1, "value": 21, "is_muted": false } } ], "now_playing": { "seek_position": \(String(position, radix: 10)), "length": \(String(length, radix: 10)), "one_line": { "line1": "A song" }, "two_line": { "line1": "A song", "line2": "That's it" }, "three_line": { "line1": "A song", "line2": "Really, that's it" } }, "state": "playing", "is_previous_allowed": true, "is_next_allowed": false, "is_pause_allowed": true, "is_play_allowed": true, "is_seek_allowed": true }, { "zone_id": "1-bar", "display_name": "Bar", "outputs": [], "state": "stopped" } ] } """ return Moo( verb: "COMPLETE", service: "Subscribed", headers: [ "Content-Type": "application/json", "Content-Length": String(body.utf8.count, radix: 10), ], body: body.data(using: .utf8)! ) case "\(TransportService.id)/seek": let res = try msg.json(type: TransportService.SeekRequest.self) position = res.seconds return Moo(verb: "COMPLETE", service: "Success") case "\(TransportService.id)/change_volume": let res = try msg.json(type: TransportService.ChangeVolumeRequest.self) print( "Got change_volume request for \(res.outputID), with \(res.value) in \(res.mode)" ) return Moo(verb: "COMPLETE", service: "Success") default: throw MockRequestError.unsupportedMessage } } func send(_ msg: consuming Moo) async throws { // No-op } } #Preview { let model = ZoneDataModel(conn: MockServer()) PlaybackBar() .environment(model) .task { do { try await model.watchChanges() } catch { print(error) } } }
-
-
macos/Plac/Views/ServerAddressForm.swift (deleted)
-
@@ -1,164 +0,0 @@// Copyright 2025 Shota FUJI // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 import Network import SwiftUI struct ServerAddressForm: View { @State private var host: String = "" @State private var port: UInt16 = 9330 @State private var isInvalidHost: Bool = false fileprivate var onConnectAction: ((_ host: String, _ port: UInt16) -> Void)? = nil private var portFormatter: NumberFormatter { let formatter = NumberFormatter() formatter.numberStyle = .none formatter.allowsFloats = false formatter.maximum = NSNumber(value: UInt16.max) formatter.minimum = 1 return formatter } private var resolvedHost: String? { guard let addr = IPv4Address(host) else { return nil } return "\(addr)" } var body: some View { NavigationStack { Form { Section { TextField(text: $host) { Text( String( localized: "ServerAddressForm.Host.Label", defaultValue: "IPv4 address" ) ) } .autocorrectionDisabled() TextField(value: $port, formatter: portFormatter) { Text( String( localized: "ServerAddressForm.Port.Label", defaultValue: "HTTP port" ) ) } .autocorrectionDisabled() } header: { Text( String( localized: "ServerAddressForm.SectionName", defaultValue: "Roon server" ) ) } footer: { if let resolvedHost = resolvedHost { Text( String( localized: "ServerAddressForm.Description", defaultValue: "By tapping Connect, Plac will open a HTTP connection to \("\(resolvedHost):\(port)")" ) ) } else { Text( String( localized: "ServerAddressForm.InvalidHost.Description", defaultValue: "\"\(host)\" is not a valid IPv4 address." ) ) } } } .navigationTitle( String( localized: "ServerAddressForm.Title", defaultValue: "Connect to server" ) ) .toolbar { ToolbarItem(placement: .confirmationAction) { Button { guard let resolvedHost = resolvedHost else { isInvalidHost = true return } onConnectAction?(resolvedHost, port) } label: { Text( String( localized: "ServerAddressForm.Connect", defaultValue: "Connect" ) ) } .alert( String( localized: "ServerAddressForm.InvalidHost.Title", defaultValue: "Fill valid IPv4 address" ), isPresented: $isInvalidHost ) { Button { isInvalidHost = false } label: { Text( String( localized: "ServerAddressForm.InvalidHost.Close", defaultValue: "Close" ) ) } } message: { Text( String( localized: "ServerAddressForm.InvalidHost.Description", defaultValue: "\"\(host)\" is not a valid IPv4 address." ) ) } } } } } } extension ServerAddressForm { func onConnect(_ handler: @escaping (_ host: String, _ port: UInt16) -> Void) -> ServerAddressForm { var new = self new.onConnectAction = handler return new } } #Preview { ServerAddressForm() .onConnect { host, port in print(host) print(port) } }
-
-
-
@@ -1,368 +0,0 @@// Copyright 2025 Shota FUJI // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 import OSLog import RoonKit import SwiftUI struct ServerDiscoveryScreen: View { @State private var servers: [RoonKit.Server] = [] @State private var status: DiscoveryStatus = .loading var onConnectAction: ((RoonKit.Server) -> Void)? private let logger = Logger() var body: some View { PureView( servers: servers, status: status, onScan: { Task { await scan() } } ) .onConnect { server in onConnectAction?(server) } .task { await scan() } } private func scan() async { status = .loading do { servers = try await RoonKit.Server.list() status = .loaded } catch { status = .failed(error) } } } extension ServerDiscoveryScreen { func onConnect(_ handler: @escaping (RoonKit.Server) -> Void) -> ServerDiscoveryScreen { var new = self new.onConnectAction = handler return new } } private enum DiscoveryStatus { case loading case loaded case failed(any Error) } private struct PureView: View { var servers: [RoonKit.Server] var status: DiscoveryStatus var onConnectAction: ((RoonKit.Server) -> Void)? var onScan: (() -> Void)? = nil @ScaledMetric private var lineSpace = 12 private var busy: Bool { switch status { case .loading: true default: false } } var body: some View { NavigationSplitView { List(servers) { server in NavigationLink { VStack(alignment: .leading, spacing: lineSpace) { HStack { Text(server.name) .font(.title) Spacer() Button { onConnectAction?(server) } label: { Text( String( localized: "ServerDiscovery.Open", defaultValue: "Open", comment: "Label for a button that starts a connection." ) ) } .buttonStyle(.borderedProminent) } VStack(alignment: .leading) { Text( String( localized: "ServerDiscovery.Version", defaultValue: "Version", comment: "A label for server version field." ) ) .font(.headline) Text(server.version) .font(.subheadline) } VStack(alignment: .leading) { Text( String( localized: "ServerDiscovery.Host", defaultValue: "IP Address", comment: "A label for server host field." ) ) .font(.headline) Text(verbatim: "\(server.host):\(String(server.port, radix: 10))") .font(.subheadline) } Spacer() } .padding() } label: { Text(server.name) } } .navigationTitle( Text( String( localized: "ServerDiscovery.Title", defaultValue: "Servers", comment: "Title of server discovery scene. Not visible on macOS." ) ) ) .toolbar { ToolbarItem(placement: .navigation) { Button { if let onScan = onScan { onScan() } } label: { Label { Text( String( localized: "ServerDiscovery.Refresh", defaultValue: "Refresh", comment: "Icon label for refresh button." ) ) } icon: { Image(systemName: "arrow.trianglehead.clockwise") } } .help( Text( String( localized: "ServerDiscovery.RefreshHelp", defaultValue: "Refresh server list", comment: "Help tooltip text for refresh button." ) ) ) .disabled(busy) } } } detail: { switch status { case .loading: ContentUnavailableView { Label { Text( String( localized: "ServerDiscovery.Loading.Heading", defaultValue: "Scanning" ) ) } icon: { Image(systemName: "waveform.badge.magnifyingglass") } .symbolEffect(.variableColor) } description: { Text( String( localized: "ServerDiscovery.Loading.Description", defaultValue: "Scanning Roon servers on local network." ) ) } case .loaded: if servers.isEmpty { ContentUnavailableView { Label { Text( String( localized: "ServerDiscovery.Empty.Heading", defaultValue: "No server found" ) ) } icon: { Image(systemName: "square.dashed") } } description: { Text( String( localized: "ServerDiscovery.Empty.Description", defaultValue: "No Roon server found on local network." ) ) } } else { Text( String( localized: "ServerDiscovery.Placeholder", defaultValue: "Select server from list", comment: "Placeholder text for server details view." ) ) } case .failed(let error): switch error { case RoonKit.ServerLookupError.socketError(_), RoonKit.ServerLookupError.connectionClosed: ContentUnavailableView { Label { Text( String( localized: "ServerDiscovery.NetworkError.Heading", defaultValue: "Scan failed" ) ) } icon: { Image(systemName: "wifi.exclamationmark") } } description: { Text( String( localized: "ServerDiscovery.NetworkError.Description", defaultValue: "Failed to scan Roon servers due to network error." ) ) } default: ContentUnavailableView { Label { Text( String( localized: "ServerDiscovery.Error.Heading", defaultValue: "Scan failed" ) ) } icon: { Image(systemName: "exclamationmark.magnifyingglass") } } description: { Text( String( localized: "ServerDiscovery.Error.Description", defaultValue: "An error occurred during a scan." ) ) } } } } } } extension PureView { func onConnect(_ handler: @escaping (RoonKit.Server) -> Void) -> PureView { var new = self new.onConnectAction = handler return new } } // MARK: - Preview #Preview("Loaded") { let logger = Logger() let servers: [RoonKit.Server] = [ .init( id: "foo", name: "Foo", version: "foo-0.0", host: "192.0.2.10", port: 9003 ), .init( id: "bar", name: "Bar", version: "bar-0.0", host: "198.51.100.8", port: 9003 ), .init( id: "baz", name: "Baz", version: "baz-0.0", host: "203.0.113.100", port: 8000 ), ] PureView(servers: servers, status: .loaded) .refreshable { logger.debug("ServerDiscoverySceneList.refreshable") } } #Preview("No servers") { PureView(servers: [], status: .loaded) } #Preview("Loading") { PureView(servers: [], status: .loading) } #Preview("SocketError") { PureView( servers: [], status: .failed( RoonKit.ServerLookupError.socketError( RoonKit.SocketError.failedToGetDescriptor ) ) ) } private enum MockUnknownError: Error { case doNotUseThisInApplicationCode } #Preview("Unknown error") { PureView( servers: [], status: .failed(MockUnknownError.doNotUseThisInApplicationCode) ) }
-
-
macos/Plac/Views/ZoneOptionsPanel.swift (deleted)
-
@@ -1,322 +0,0 @@// Copyright 2025 Shota FUJI // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 import OSLog import RoonKit import SwiftUI struct ZoneOptionsPanel: View { private var model: ZoneDataModel init(model: ZoneDataModel) { self.model = model } var body: some View { @Bindable var model = model Form { Picker(selection: $model.zone) { ForEach(model.zones) { (zone: TransportService.Zone) in Text(zone.displayName).tag(zone) } } label: { Text( String( localized: "ZoneOptionsPanel.ZonePicker", defaultValue: "Zone" ) ) } if let zone = model.zone { ForEach(zone.outputs) { output in Section { OutputRow(output: output, model: model) } header: { Text(output.displayName) } } } } .formStyle(.grouped) } } private struct OutputRow: View { private let logger = Logger() private let output: TransportService.Output private let model: ZoneDataModel @State private var volume: Float64 var body: some View { switch output.volume?.type { case .some("incremental"): Stepper( label: { Text( String( localized: "ZoneOptionsPanel.StepVolumeLabel", defaultValue: "Volume Control" ) ) }, onIncrement: { Task { do { let res = try await model.conn.request( .init( changeVolume: .init( outputID: output.id, value: 1.0, mode: .relative ) ) ) _ = try TransportService.ChangeVolumeResponse(res) } catch { logger.warning("Failed to increment volume: \(error)") } } }, onDecrement: { Task { do { let res = try await model.conn.request( .init( changeVolume: .init( outputID: output.id, value: -1.0, mode: .relative ) ) ) _ = try TransportService.ChangeVolumeResponse(res) } catch { logger.warning("Failed to decrement volume: \(error)") } } } ) case .none: Text( String( localized: "ZoneOptionsPanel.NoVolumeControl", defaultValue: "This output does not support volume control." ) ) case .some(let type): let min = output.volume?.min ?? 0 let max = output.volume?.max ?? 100 let format = { (value: Float64) in switch type { case "db": String(format: "%1.fdB", value) default: String(format: "%.f%%", ((value - min) / max) * 100) } } VStack { Slider( value: $volume, in: .init(uncheckedBounds: (min, max)), ) { Text( String( localized: "ZoneOptionsPanel.VolumeSlider", defaultValue: "Volume", comment: "A label for volume slider." ) ) } minimumValueLabel: { Text(format(min)) } maximumValueLabel: { Text(format(max)) } onEditingChanged: { editing in // Issue API request only when user stopped editing (e.g., lift a finger) guard editing == false else { return } Task { do { let res = try await model.conn.request( .init( changeVolume: .init( outputID: output.id, value: volume, mode: .absolute ) ) ) _ = try TransportService.ChangeVolumeResponse(res) } catch { logger.warning("Failed to change volume: \(error)") } } } Text(format(volume)) } } } init(output: TransportService.Output, model: ZoneDataModel) { self.model = model self.output = output self.volume = output.volume?.value ?? 0 } } // MARK: - Previews private actor MockServer: Communicatable { var messages: AsyncStream<Moo> { AsyncStream { stream in } } func request(_ msg: consuming Moo) async throws -> Moo { try await request(msg: msg, timeout: nil) } func request(_ msg: consuming Moo, timeout: ContinuousClock.Instant.Duration) async throws -> Moo { try await request(msg: msg, timeout: timeout) } fileprivate enum MockRequestError: Error { case unsupportedMessage case invalidRequest } private func request( msg: Moo, timeout: ContinuousClock.Instant.Duration? = nil ) async throws -> Moo { switch msg.service { case "\(TransportService.id)/subscribe_zones": let body = """ { "zones": [ { "zone_id": "0-foo", "display_name": "Foo", "outputs": [ { "output_id": "foo-out1", "display_name": "Foo Output #1", "volume": { "type": "number", "min": 0, "max": 100, "step": 1, "value": 50, "is_muted": false } }, { "output_id": "foo-out2", "display_name": "Foo Output #2", "volume": { "type": "incremental" } }, { "output_id": "foo-out3", "display_name": "Foo Output #3", "volume": { "type": "db", "min": 0, "max": 85, "step": 0.1, "value": 21, "is_muted": false } } ], "now_playing": { "seek_position": 0, "length": 10, "one_line": { "line1": "A song" }, "two_line": { "line1": "A song", "line2": "That's it" }, "three_line": { "line1": "A song", "line2": "Really, that's it" } }, "state": "playing", "is_previous_allowed": true, "is_next_allowed": false, "is_pause_allowed": true, "is_play_allowed": true, "is_seek_allowed": true }, { "zone_id": "1-bar", "display_name": "Bar", "outputs": [], "state": "stopped" } ] } """ return Moo( verb: "COMPLETE", service: "Subscribed", headers: [ "Content-Type": "application/json", "Content-Length": String(body.utf8.count, radix: 10), ], body: body.data(using: .utf8)! ) case "\(TransportService.id)/change_volume": let res = try msg.json(type: TransportService.ChangeVolumeRequest.self) print( "Got change_volume request for \(res.outputID), with \(res.value) in \(res.mode)" ) return Moo(verb: "COMPLETE", service: "Success") default: throw MockRequestError.unsupportedMessage } } func send(_ msg: consuming Moo) async throws { // No-op } } #Preview { let model = ZoneDataModel(conn: MockServer()) Task { try? await model.watchChanges() } return ZoneOptionsPanel(model: model) }
-
-
-
@@ -1,66 +0,0 @@// Copyright 2025 Shota FUJI // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 import RoonKit import Testing @testable import Plac struct BrowsingDataModelTests { @Test func groupItemsByPresenseOfImageKey() async throws { let items: [BrowseService.Item] = [ .init(title: "Foo"), .init(title: "Bar", imageKey: "bar"), .init(title: "Baz", imageKey: "baz"), ] let groups = BrowseItemGroup.groupInto(items: items) #expect( groups == [ .init(items: [ .init(title: "Foo") ]), .init(items: [ .init(title: "Bar", imageKey: "bar"), .init(title: "Baz", imageKey: "baz"), ]), ] ) } @Test func groupItemsByPresenceOfSubtitle() async throws { let items: [BrowseService.Item] = [ .init(title: "Foo", subtitle: "foo-foo"), .init(title: "Bar"), .init(title: "Baz"), ] let groups = BrowseItemGroup.groupInto(items: items) #expect( groups == [ .init(items: [ .init(title: "Foo", subtitle: "foo-foo") ]), .init(items: [ .init(title: "Bar"), .init(title: "Baz"), ]), ] ) } }
-
-
macos/README.md (deleted)
-
@@ -1,30 +0,0 @@<!-- Copyright 2025 Shota FUJI Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. SPDX-License-Identifier: Apache-2.0 --> # Plac for macOS macOS native application, fully written in Swift. ## Install Currently, there is no prebuilt binary of Plac for macOS. You have to build from source with Xcode and install the built application or run in debug mode using Xcode. ## Build To build Plac for macOS, you need recent versions of Xcode (tested on Xcode 16.4, older versions might work.)
-
-
macos/REUSE.toml (deleted)
-
@@ -1,12 +0,0 @@version = 1 [[annotations]] path = [ "Plac/**/*.json", "Plac/**/*.entitlements", "Plac.xcodeproj/**/*", "Plac.xctestplan", "*/.swiftpm/**/*", ] SPDX-FileCopyrightText = "Shota FUJI" SPDX-License-Identifier = "Apache-2.0"
-
-
-
@@ -1,79 +0,0 @@<?xml version="1.0" encoding="UTF-8"?> <Scheme LastUpgradeVersion = "1640" version = "1.7"> <BuildAction parallelizeBuildables = "YES" buildImplicitDependencies = "YES" buildArchitectures = "Automatic"> <BuildActionEntries> <BuildActionEntry buildForTesting = "YES" buildForRunning = "YES" buildForProfiling = "YES" buildForArchiving = "YES" buildForAnalyzing = "YES"> <BuildableReference BuildableIdentifier = "primary" BlueprintIdentifier = "RoonKit" BuildableName = "RoonKit" BlueprintName = "RoonKit" ReferencedContainer = "container:"> </BuildableReference> </BuildActionEntry> </BuildActionEntries> </BuildAction> <TestAction buildConfiguration = "Debug" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" shouldUseLaunchSchemeArgsEnv = "YES" shouldAutocreateTestPlan = "YES"> <Testables> <TestableReference skipped = "NO"> <BuildableReference BuildableIdentifier = "primary" BlueprintIdentifier = "RoonKitTests" BuildableName = "RoonKitTests" BlueprintName = "RoonKitTests" ReferencedContainer = "container:"> </BuildableReference> </TestableReference> </Testables> </TestAction> <LaunchAction buildConfiguration = "Debug" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" launchStyle = "0" useCustomWorkingDirectory = "NO" ignoresPersistentStateOnLaunch = "NO" debugDocumentVersioning = "YES" debugServiceExtension = "internal" allowLocationSimulation = "YES"> </LaunchAction> <ProfileAction buildConfiguration = "Release" shouldUseLaunchSchemeArgsEnv = "YES" savedToolIdentifier = "" useCustomWorkingDirectory = "NO" debugDocumentVersioning = "YES"> <MacroExpansion> <BuildableReference BuildableIdentifier = "primary" BlueprintIdentifier = "RoonKit" BuildableName = "RoonKit" BlueprintName = "RoonKit" ReferencedContainer = "container:"> </BuildableReference> </MacroExpansion> </ProfileAction> <AnalyzeAction buildConfiguration = "Debug"> </AnalyzeAction> <ArchiveAction buildConfiguration = "Release" revealArchiveInOrganizer = "YES"> </ArchiveAction> </Scheme>
-
-
macos/RoonKit/Package.swift (deleted)
-
@@ -1,53 +0,0 @@// swift-tools-version: 6.1 // Copyright 2025 Shota FUJI // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 import PackageDescription let package = Package( name: "RoonKit", platforms: [ .macOS(.v14), .iOS(.v16) ], products: [ // Products define the executables and libraries a package produces, making them visible to other packages. .library( name: "RoonKit", targets: ["RoonKit"] ) ], dependencies: [ .package(url: "https://github.com/apple/swift-nio.git", from: "2.0.0") ], targets: [ // Targets are the basic building blocks of a package, defining a module or a test suite. // Targets can depend on other targets in this package and products from dependencies. .target( name: "RoonKit", dependencies: [ .product(name: "NIOCore", package: "swift-nio"), .product(name: "NIOPosix", package: "swift-nio"), .product(name: "NIOFoundationCompat", package: "swift-nio"), ] ), .testTarget( name: "RoonKitTests", dependencies: ["RoonKit"] ), ] )
-
-
-
@@ -1,60 +0,0 @@// Copyright 2025 Shota FUJI // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 public enum MessageReadError: Error { case connectionClosed case timeout } public enum MessageWriteError: Error { case connectionNotReady } public protocol Communicatable: Actor { /// A stream of incoming messages. Roon API uses WebSocket for most of the operation, /// even including request-response methods. Due to this nature, caller must check /// "Request-Id" header field to determine if a message is a response for the request. /// /// The stream will end when the underlying connection was closed. /// /// This is low-level API. Only use for continuous reading, such as change subscription. var messages: AsyncStream<Moo> { get } /// Send a MOO message to connected Roon server and returns a response for the message. /// /// This function sets "Request-Id" header if the message does not have one. /// Unless you have a specific reason, let this function set it so the ID will be unique among other requests. /// /// Throws when send failed or connection closed during the read. func request(_ msg: consuming Moo) async throws -> Moo /// Send a MOO message to connected Roon server and returns a response for the message. /// /// This function sets "Request-Id" header if the message does not have one. /// Unless you have a specific reason, let this function set it so the ID will be unique among other requests. /// /// Throws when send failed or connection closed during the read. /// /// If no response was made when the `timeout` elapsed, `MessageReadError.timeout` error will be thrown. func request(_ msg: consuming Moo, timeout: ContinuousClock.Instant.Duration) async throws -> Moo /// Sends a MOO message to the connected Roon server, then returns. /// This function does not waits for a response message. /// /// Throws when a network error occured during send operation. func send(_ msg: consuming Moo) async throws }
-
-
-
@@ -1,40 +0,0 @@// Copyright 2025 Shota FUJI // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 public protocol Connectable: Actor { /// Roon server ID of the target Roon Core. var serverID: String? { get } /// Host, mostly IPv4 address of the target Roon Core. var host: String { get } /// HTTP port to use for WebSocket connection to the target Roon Core. var port: UInt16 { get } /// Open a new connection if the current connection is closed. /// Throws when the current connection is already open. func connect() async throws /// Close the current connection. func disconnect() /// Waits for connection to be closed. /// /// This method returns when the connection was closed. /// If the connection is already closed at the time of this method call, /// this method immediately returns. func lifetime() async }
-
-
-
@@ -1,467 +0,0 @@// Copyright 2025 Shota FUJI // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 import Network import OSLog import Observation public actor Connection: Connectable, Communicatable { private let logger = Logger(subsystem: subsystem, category: "Connection") public private(set) var serverID: String? public let host: String public let port: UInt16 public let ext: RegistryService.Extension public private(set) var token: String? = nil private var conn: NWConnection private var writeContext: NWConnection.ContentContext private let queue = DispatchQueue(label: "RoonKit.Connection.network") private var requestID: RequestId = 0 private var messageContinuationID: UInt = 0 private var messageContinuations: [UInt: AsyncStream<Moo>.Continuation] = [:] private var lifetimeContinuations: [CheckedContinuation<Void, Never>] = [] private var readLoop: Task<Void, any Error>? = nil public enum InitError: Error { case invalidHost } public init( id: String? = nil, host: String, port: UInt16, ext: RegistryService.Extension ) async throws { self.serverID = id self.host = host self.port = port self.ext = ext self.token = ext.token let metadata = NWProtocolWebSocket.Metadata(opcode: .binary) self.writeContext = NWConnection.ContentContext( identifier: "writeBinaryContext", metadata: [metadata] ) var url = URLComponents() url.scheme = "ws" url.host = host url.port = Int(port) url.path = "/api" guard let url = url.url else { throw InitError.invalidHost } let options = NWProtocolWebSocket.Options.init() options.autoReplyPing = true let parameters = NWParameters.tcp parameters.defaultProtocolStack.applicationProtocols.insert(options, at: 0) self.conn = NWConnection.init(to: .url(url), using: parameters) try await self.connect() } deinit { logger.info("Disconnecting... (automatic)") if let readLoop = readLoop { readLoop.cancel() self.readLoop = nil } self.messageContinuations = [:] for cont in self.lifetimeContinuations { cont.resume() } self.lifetimeContinuations = [] if conn.state != .cancelled { conn.cancel() } } public enum ConnectError: Error { case alreadyStarted case unexpectedInfoReceived case serverIDMismatch case cancelled case connectionNotReady(any Error) } public func connect() async throws { switch conn.state { case .failed(let error): throw error case .cancelled, .setup: break default: throw ConnectError.alreadyStarted } logger.debug("Starting WebSocket connection") conn.start(queue: queue) do { try await conn.ready() } catch { switch error { case NWConnection.ConnectionNotReadyError.cancelled: throw ConnectError.cancelled case NWConnection.ConnectionNotReadyError.failed(let error): throw ConnectError.connectionNotReady(error) default: throw error } } logger.debug("WebSocket connection is ready") self.readLoop = startReadLoop() conn.stateUpdateHandler = { [weak self] (state: NWConnection.State) in switch state { case .cancelled, .failed(_), .waiting(_): Task { self?.logger.info("Network connection closed") await self?.disconnect() } case .preparing: self?.logger.debug("Network is in preparing state") case .setup: self?.logger.debug("Network has reset to setup state") default: break } } try await withTaskCancellationHandler { do { logger.debug("Querying server info") let res = try await request(Moo(info: .init()), timeout: .seconds(3)) let info = try RegistryService.InfoResponse(res) if let serverID = serverID { guard info.coreId == serverID else { throw ConnectError.serverIDMismatch } } else { serverID = info.coreId } } do { logger.debug("Registering extension") // This stays pending until user accepts the extension on Roon settings page. // Because of this, setting timeout does not make sense here. let res = try await request(Moo(register: ext)) let result = try RegistryService.RegisterResponse(res) token = result.token } } onCancel: { Task.detached { await self.conn.forceCancel() } } } private func clearMessageContinuations() { for (_, continuation) in messageContinuations { continuation.finish() } messageContinuations.removeAll() } private func startReadLoop() -> Task<Void, any Error> { // This loop task is a field of the actor containing it--every reference to // the actor (self) should be weak otherwise they form circular reference // and the actor will not be released via ARC. Task { [weak self] in while true { try Task.checkCancellation() do { guard let self = self else { return } let (data, context, _) = try await conn.receiveMessage() guard let data = data else { continue } guard let metadata = context?.protocolMetadata.first as? NWProtocolWebSocket.Metadata else { logger.warning( "Received non-WebSocket message on WebSocket connection" ) continue } switch metadata.opcode { case .binary, .text: guard let msg = Moo.init(String(data: data, encoding: .utf8)!) else { logger.warning("Received invalid MOO message") continue } if msg.service == PingService.Ping.service { Task { [weak self] in var res = Moo(verb: "COMPLETE", service: "Success") res.requestId = msg.requestId guard let self = self else { return } try await send(res) } continue } for (_, continuation) in await messageContinuations { continuation.yield(msg) } case .close: logger.info("Connection closed, releasing resources") return default: break } } catch { switch error { case NWError.posix(.ENODATA): if let self = self { logger.warning("Stream closed, disconnecting") await self.disconnect() return } default: break } if let logger = self?.logger { logger.warning("Receive failed: \(error)") } return } } } } public func disconnect() { logger.info("Disconnecting... (manual)") if let readLoop = readLoop { readLoop.cancel() self.readLoop = nil } self.messageContinuations = [:] for cont in self.lifetimeContinuations { cont.resume() } self.lifetimeContinuations = [] if conn.state != .cancelled { conn.cancel() } } public func lifetime() async { guard self.conn.state != .cancelled else { return } await withCheckedContinuation { (cont: CheckedContinuation<Void, Never>) in self.lifetimeContinuations.append(cont) } } public var messages: AsyncStream<Moo> { let id = messageContinuationID messageContinuationID += 1 return AsyncStream { cont in messageContinuations[id] = cont cont.onTermination = { @Sendable _ in Task.detached { await self.deleteMessageContinuation(id) } } } } private func request( msg: consuming Moo, timeout: ContinuousClock.Instant.Duration? = nil ) async throws -> Moo { let requestID: RequestId if let givenID = msg.requestId { requestID = givenID } else { requestID = self.requestID msg.requestId = requestID self.requestID += 1 } let timeoutTask: Task<Void, any Error>? if let timeout = timeout { timeoutTask = Task { try await Task.sleep(for: timeout) throw MessageReadError.timeout } } else { timeoutTask = nil } let read = Task { for await msg in self.messages { guard msg.requestId == .some(requestID) else { continue } timeoutTask?.cancel() return msg } throw MessageReadError.connectionClosed } try await self.send(msg) return try await read.value } public func request(_ msg: consuming Moo) async throws -> Moo { return try await request(msg: msg) } public func request( _ msg: consuming Moo, timeout: ContinuousClock.Instant.Duration ) async throws -> Moo { return try await request(msg: msg, timeout: timeout) } public func send(_ msg: consuming Moo) async throws { let req = String(msg).data(using: .utf8)! try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, any Error>) in if conn.state != .ready { cont.resume(throwing: MessageWriteError.connectionNotReady) return } conn.send( content: req, contentContext: writeContext, completion: .contentProcessed({ error in if let error = error { cont.resume(throwing: error) return } cont.resume() }) ) } } private func deleteMessageContinuation(_ id: UInt) { self.messageContinuations[id] = nil } } extension NWConnection { fileprivate enum ConnectionNotReadyError: Error { case failed(NWError) case cancelled } fileprivate func ready() async throws { try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, any Error>) in self.stateUpdateHandler = { state in switch state { case .failed(let error), .waiting(let error): cont.resume(throwing: ConnectionNotReadyError.failed(error)) self.stateUpdateHandler = nil case .cancelled: cont.resume(throwing: ConnectionNotReadyError.cancelled) self.stateUpdateHandler = nil case .ready: cont.resume() self.stateUpdateHandler = nil default: break } } } } fileprivate func receiveMessage() async throws -> ( Data?, ContentContext?, Bool ) { return try await withCheckedThrowingContinuation { cont in self.receiveMessage { (data, context, received, error) in if let error = error { cont.resume(throwing: error) return } cont.resume(returning: (data, context, received)) } } } fileprivate func send( _ data: Data, contentContext: ContentContext = ContentContext.defaultMessage ) async throws { try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, any Error>) in self.send( content: data, contentContext: contentContext, completion: .contentProcessed({ error in if let error = error { cont.resume(throwing: error) return } cont.resume() }) ) } } }
-
-
macos/RoonKit/Sources/RoonKit/Moo.swift (deleted)
-
@@ -1,350 +0,0 @@// Copyright 2025 Shota FUJI // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 import Foundation public typealias Headers = [String: String] let signature = "MOO/" enum MooMessageDecodeError: Error { case signatureMismatch case invalidVersion case missingVerb case missingService case malformedHeaderLine } /// MOO is a message format represented as a newline-delimited UTF-8 encoded text. /// A client (Roon Extension) and Roon Server communicates via exchanging MOO message over /// WebSocket connection. /// /// MOO message has medatada, headers, and optional body. /// /// Headers is a list of key-value, where key and value is separated by `:` (colon.) /// /// If there is a body, typical MOO message has `Content-Type` header and its value describes /// the type of the body content. E.g. `application/json` public struct Moo: LosslessStringConvertible, Sendable, Copyable, Escapable { /// Message schema version described in metadata. /// This field will be ignored as I have no idea how to use and validate this field. public let schemaVersion: UInt /// Describes semantic role of the message, such as `REQUEST`, `CONTINUE` and `COMPLETE`. /// It's totally unclear why they insists on WebSocket when normal HTTP natively supports these /// semantics and many clients support in platform level. public let verb: String /// Name (actually identifier) of a Roon API "Service" if `verb` is `REQUEST`. /// Otherwise, this will be abused as a response status field, such as `Success` or `~Error`. public let service: String /// A MOO message has HTTP-like headers. Every request has `Request-Id` header, which identifies which request /// the message corresponds to. Most of the time, request ID is number. However, Roon Server seems to just return /// whatever is in the request message. public internal(set) var headers: Headers public let body: String? public var description: String { var description = "\(signature)\(String(self.schemaVersion)) \(self.verb) \(self.service)\n" for (key, value) in self.headers { description += "\(key): \(value)\n" } if self.headers.count > 0 { description += "\n" } if let body = self.body { description += body } return description } public var requestId: RequestId? { get { guard let value = headers["Request-Id"] else { return nil } return RequestId(value) } set { headers["Request-Id"] = newValue.map { String($0) } } } public init( schemaVersion: UInt = 1, verb: String = "REQUEST", service: String, headers: Headers = [:], body: Data? = nil, requestId: RequestId? = nil, ) { self.schemaVersion = schemaVersion self.verb = verb self.service = service self.headers = headers self.body = body.flatMap { String(data: $0, encoding: .utf8) } self.requestId = requestId } public init?(_ from: String) { try? self.init(from: from) } public init<T>( jsonBody: T, schemaVersion: UInt = 1, verb: String = "REQUEST", service: String, headers: Headers = [:], requestId: RequestId? = nil ) throws where T: Encodable { let body = try JSONEncoder().encode(jsonBody) var headers = headers headers["Content-Type"] = "application/json" headers["Content-Length"] = String(body.count, radix: 10) self.init( schemaVersion: schemaVersion, verb: verb, service: service, headers: headers, body: body, requestId: requestId ) } private enum HeaderParseState { case key(found: String) case beforeValue(key: String) case value(key: String, found: String) } private enum ParseState { case signature(expecting: Character, remaining: Substring) case schemaVersion(found: String) case verb(found: String) case service(found: String) case headers(parsing: HeaderParseState, parsed: Headers) case body(found: String) } init(from: String) throws(MooMessageDecodeError) { var state: ParseState = .signature( expecting: signature.first!, remaining: signature.dropFirst() ) var schemaVersion: UInt? = nil var verb: String? = nil var service: String? = nil var headers: Headers = [:] if from.count == 0 { throw .signatureMismatch } for char in from { switch state { case .signature(let expecting, let remaining): if char != expecting { throw .signatureMismatch } guard let next = remaining.first else { state = .schemaVersion(found: "") break } state = .signature(expecting: next, remaining: remaining.dropFirst()) break case .schemaVersion(let found): if char.isWhitespace && !char.isNewline { if found == "" { throw .invalidVersion } schemaVersion = UInt(found, radix: 10) state = .verb(found: "") break } if !char.isNumber || (found == "" && char == "0") { throw .invalidVersion } state = .schemaVersion(found: found + String(char)) break case .verb(let found): if char.isNewline { throw .missingService } if char.isWhitespace { verb = found state = .service(found: "") break } state = .verb(found: found + String(char)) break case .service(let found): if char.isNewline { service = found state = .headers(parsing: .key(found: ""), parsed: [:]) break } state = .service(found: found + String(char)) break case .headers(let parsing, let parsed): switch parsing { case .key(let found): if found == "" && char.isNewline { headers = parsed state = .body(found: "") break } if char == ":" { if found == "" { throw .malformedHeaderLine } state = .headers(parsing: .beforeValue(key: found), parsed: parsed) break } if !char.isASCII { throw .malformedHeaderLine } state = .headers( parsing: .key(found: found + String(char)), parsed: parsed ) break case .beforeValue(let key): if char.isNewline { throw .malformedHeaderLine } if char.isWhitespace { break } state = .headers( parsing: .value(key: key, found: String(char)), parsed: parsed ) break case .value(let key, let found): if char.isNewline { var parsed = parsed _ = parsed.updateValue(found, forKey: key) state = .headers(parsing: .key(found: ""), parsed: parsed) break } state = .headers( parsing: .value(key: key, found: found + String(char)), parsed: parsed ) break } case .body(let found): state = .body(found: found + String(char)) break } } guard let schemaVersion = schemaVersion else { throw .invalidVersion } guard let verb = verb, verb.count > 0 else { throw .missingVerb } guard let service = service, service.count > 0 else { throw .missingService } self.schemaVersion = schemaVersion self.verb = verb self.service = service self.headers = headers if case .body(let found) = state, found.count > 0 { self.body = found } else { self.body = nil } } public enum JsonBodyError: Error { case decodeError(any Error) case contentTypeMismatch(got: String?) case invalidContentLength(found: String?) case contentLengthMismatch(told: Int, found: Int) case emptyBody } public func json<T>(type: T.Type) throws(JsonBodyError) -> T where T: Decodable { guard let body = self.body else { throw .emptyBody } guard let contentType = self.headers["Content-Type"] else { throw .contentTypeMismatch(got: nil) } guard contentType == "application/json" || contentType.starts(with: "application/json;") else { throw .contentTypeMismatch(got: contentType) } guard let contentLength = self.headers["Content-Length"].flatMap({ Int($0, radix: 10) }) else { throw .invalidContentLength(found: self.headers["Content-Length"]) } let data = body.data(using: .utf8)! guard data.count == contentLength else { throw .contentLengthMismatch(told: contentLength, found: data.count) } do { return try JSONDecoder().decode(type, from: data) } catch { throw .decodeError(error) } } }
-
-
-
@@ -1,21 +0,0 @@// Copyright 2025 Shota FUJI // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 import os public typealias RequestId = UInt internal let subsystem = "jp.pocka.plac.RoonKit"
-
-
-
@@ -1,145 +0,0 @@// Copyright 2025 Shota FUJI // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 import Foundation import NIOCore import NIOFoundationCompat import NIOPosix import OSLog private let multicastAddr = ("239.255.90.90", 9003) private let logger = Logger(subsystem: subsystem, category: "ServerDiscovery") public enum SocketError: Error { case failedToGetDescriptor case setReuseAddrFailed(Int32) case setReceiveTimeoutFailed(Int32) case setBroadcastOptionFailed(Int32) } public enum ServerLookupError: Error { case notFound case unableToGetAddress case connectionClosed case unableToBuildUrl case socketError(SocketError) } public nonisolated struct Server: Identifiable, Sendable { public let host: String public let port: UInt16 public let id: String public let name: String public let version: String public init( id: String, name: String, version: String, host: String, port: UInt16 ) { self.id = id self.name = name self.version = version self.host = host self.port = port } public static func list() async throws -> [Server] { let queue = DispatchQueue(label: "RoonKit.Server.list") let group = MultiThreadedEventLoopGroup(numberOfThreads: 1) defer { group.shutdownGracefully(queue: queue) { _ in } } let channel = try await DatagramBootstrap(group: group) .channelOption(ChannelOptions.socketOption(.so_reuseaddr), value: 1) .bind(host: "0.0.0.0", port: 0) { channel in channel.eventLoop.makeCompletedFuture { return try NIOAsyncChannel< AddressedEnvelope<ByteBuffer>, AddressedEnvelope<ByteBuffer> >( wrappingChannelSynchronously: channel, ) } } async let read = try await channel.executeThenClose { inbound, outbound in var found: [String: Server] = [:] for try await envelope in inbound { do { let res = try ServerDiscoveryResponse( from: Data(buffer: envelope.data) ) logger.debug("Received a valid SOOD message") guard let ipAddress = envelope.remoteAddress.ipAddress else { continue } logger.debug("Found a server at \(envelope.remoteAddress)") found[res.uniqueId] = Server( id: res.uniqueId, name: res.displayName, version: res.version, host: ipAddress, port: res.httpPort ) } catch { logger.warning("Received an invalid SOOD message") } } return found } async let _ = channel.executeThenClose { inbound, outbound in let req = ServerDiscoveryRequest().data() for _ in 1...4 { do { logger.debug( "Sending a discovery query to \(multicastAddr.0):\(multicastAddr.1)" ) try await outbound.write( AddressedEnvelope( remoteAddress: .init( ipAddress: multicastAddr.0, port: multicastAddr.1 ), data: ByteBuffer(data: req), ) ) } catch { logger.warning("Unable to send discovery query: \(error)") } try await Task.sleep(for: .seconds(3)) } } let found = try await read return found.values.sorted(by: { a, b in a.id.hash < b.id.hash }) } }
-
-
-
@@ -1,30 +0,0 @@// Copyright 2025 Shota FUJI // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 import Foundation struct ServerDiscoveryRequest { let queryServiceId: String = "00720724-5143-4a9b-abac-0e50cba674bb" func data() -> Data { let msg = Sood( kind: .query, properties: ["query_service_id": queryServiceId] ) return msg.data() } }
-
-
-
@@ -1,79 +0,0 @@// Copyright 2025 Shota FUJI // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 import Foundation enum ServerDiscoveryResponseProperty: Sendable, Equatable { case uniqueId case displayName case version case httpPort } enum ServerDiscoveryResponseParseError: Error, Equatable { case invalidMessage(SoodDecodeError) case unexpectedMessageKind case missingProperty(ServerDiscoveryResponseProperty) case illegalHttpPort } struct ServerDiscoveryResponse { let uniqueId: String let displayName: String let version: String let httpPort: UInt16 init(from: Data) throws(ServerDiscoveryResponseParseError) { let msg: Sood do { msg = try Sood(from: from) } catch { throw .invalidMessage(error) } if msg.kind != .response { throw .unexpectedMessageKind } if let uniqueId = msg.properties["unique_id"] { self.uniqueId = uniqueId } else { throw .missingProperty(.uniqueId) } if let displayName = msg.properties["name"] { self.displayName = displayName } else { throw .missingProperty(.displayName) } if let version = msg.properties["display_version"] { self.version = version } else { throw .missingProperty(.version) } if let httpPort = msg.properties["http_port"] { guard let httpPort = UInt16(httpPort, radix: 10) else { throw .illegalHttpPort } self.httpPort = httpPort } else { throw .missingProperty(.httpPort) } } }
-
-
-
@@ -1,421 +0,0 @@// Copyright 2025 Shota FUJI // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 public struct BrowseService { public static let id = "com.roonlabs.browse:1" public enum ItemHint: LosslessStringConvertible, Codable, Hashable, Sendable { case unknown(String) case action, actionList case list case header public var description: String { switch self { case .action: "action" case .actionList: "action_list" case .header: "header" case .list: "list" case .unknown(let string): string } } public init(_ description: String) { switch description { case "action": self = .action case "action_list": self = .actionList case "header": self = .header case "list": self = .list default: self = .unknown(description) } } public init(from decoder: any Decoder) throws { let value = try decoder.singleValueContainer() let string = try value.decode(String.self) self.init(string) } public func encode(to encoder: any Encoder) throws { var value = encoder.singleValueContainer() try value.encode(self.description) } } public struct InputPrompt: Codable, Hashable, Sendable { public let prompt: String public let action: String public let value: String? let _isPassword: Bool? public var isPassword: Bool { _isPassword ?? false } public enum CodingKeys: String, CodingKey { case prompt = "prompt" case action = "action" case value = "value" case _isPassword = "is_password" } } private static func stripInternalLinks(text: String) -> String { var source = text[...] var components: [Substring] = [] while true { guard let starts = source.firstRange(of: "[[") else { components.append(source) break } guard let idEnds = source.firstIndex(of: "|") else { components.append(source) break } guard let ends = source.firstRange(of: "]]") else { components.append(source) break } guard starts.upperBound < idEnds, idEnds < ends.lowerBound else { components.append(source) break } components.append(source[..<starts.lowerBound]) components.append(source[source.index(after: idEnds)..<ends.lowerBound]) if ends.upperBound >= source.index(before: source.endIndex) { break } source = source[ends.upperBound...] } return components.joined() } public struct Item: Codable, Hashable, Sendable { public let title: String public let subtitle: String? public let imageKey: ImageService.Key? public let itemKey: String? public let hint: ItemHint? public let inputPrompt: InputPrompt? public enum CodingKeys: String, CodingKey { case title = "title" case subtitle = "subtitle" case imageKey = "image_key" case itemKey = "item_key" case hint = "hint" case inputPrompt = "input_prompt" } public init( title: String, subtitle: String? = nil, imageKey: ImageService.Key? = nil, itemKey: String? = nil, hint: ItemHint? = nil, inputPrompt: InputPrompt? = nil ) { self.title = title self.subtitle = subtitle self.imageKey = imageKey self.itemKey = itemKey self.hint = hint self.inputPrompt = inputPrompt } /// Newer browsing pages, such as third-party cloud service's pages, *may* return /// `[[ID|label]]` formatted text for subtitle. As the text will be nearly unreadable, /// this helper getter function parses that and returns label part. The format is not specified. public var correctedSubtitle: String? { guard let subtitle = subtitle else { return nil } return stripInternalLinks(text: subtitle) } } public enum ListHint: LosslessStringConvertible, Codable, Sendable { case unknown(String) case actionList public var description: String { switch self { case .actionList: "action_list" case .unknown(let string): string } } public init(_ description: String) { switch description { case "action_list": self = .actionList default: self = .unknown(description) } } public init(from decoder: any Decoder) throws { let value = try decoder.singleValueContainer() let string = try value.decode(String.self) self.init(string) } public func encode(to encoder: any Encoder) throws { var value = encoder.singleValueContainer() try value.encode(self.description) } } public struct List: Codable, Sendable { public let title: String public let subtitle: String? public let imageKey: ImageService.Key? public let displayOffset: Int? public let hint: ListHint? let _count: UInt? let _level: UInt? public var count: UInt { _count ?? 0 } public var level: UInt { _level ?? 0 } public enum CodingKeys: String, CodingKey { case title = "title" case _count = "count" case subtitle = "subtitle" case imageKey = "image_key" case _level = "level" case displayOffset = "display_offset" case hint = "hint" } /// Newer browsing pages, such as third-party cloud service's pages, *may* return /// `[[ID|label]]` formatted text for subtitle. As the text will be nearly unreadable, /// this helper getter function parses that and returns label part. The format is not specified. public var correctedSubtitle: String? { guard let subtitle = subtitle else { return nil } return stripInternalLinks(text: subtitle) } } public enum Hierarchy: String, Codable { case browse, playlists, settings, albums case artists, genres, composers, search case internetRadio = "internet_radio" } } // MARK: - Move server-side browsing location extension BrowseService { public enum BrowseAction: String, Codable { case message, none, list case replaceItem = "replace_item" case removeItem = "remove_item" } public struct BrowseRequest: Codable { public let hierarchy: Hierarchy public let multiSessionKey: String? public let itemKey: String? public let input: String? public let zoneOrOutputID: String? public let popAll: Bool? public let popLevels: UInt? public let refreshList: Bool? public enum CodingKeys: String, CodingKey { case hierarchy = "hierarchy" case multiSessionKey = "multi_session_key" case itemKey = "item_key" case input = "input" case zoneOrOutputID = "zone_or_output_id" case popAll = "pop_all" case popLevels = "pop_levels" case refreshList = "refresh_list" } public init( hierarchy: Hierarchy, multiSessionKey: String? = nil, itemKey: String? = nil, input: String? = nil, zoneOrOutputID: String? = nil, popAll: Bool? = nil, popLevels: UInt? = nil, refreshList: Bool? = nil ) { self.hierarchy = hierarchy self.multiSessionKey = multiSessionKey self.itemKey = itemKey self.input = input self.zoneOrOutputID = zoneOrOutputID self.popAll = popAll self.popLevels = popLevels self.refreshList = refreshList } } public struct BrowseResponse: Codable { public let action: BrowseAction public let item: Item? public let list: List? public let message: String? let _isError: Bool? public var isError: Bool { _isError ?? false } public enum CodingKeys: String, CodingKey { case action = "action" case item = "item" case list = "list" case message = "message" case _isError = "is_error" } public enum DecodeError: Error { case nonSuccess case invalidBody(Moo.JsonBodyError) } public init(_ msg: Moo) throws(DecodeError) { guard msg.service == "Success" else { throw .nonSuccess } do { self = try msg.json(type: Self.self) } catch { throw .invalidBody(error) } } } } extension Moo { public init(browse: BrowseService.BrowseRequest) throws { try self.init(jsonBody: browse, service: "\(BrowseService.id)/browse") } } // MARK: - Load items at server-side browsing location extension BrowseService { public struct LoadRequest: Codable { public let hierarchy: Hierarchy public let multiSessionKey: String? public let displayOffset: Int? public let level: UInt? public let offset: Int? public let count: UInt? public enum CodingKeys: String, CodingKey { case hierarchy = "hierarchy" case multiSessionKey = "multi_session_key" case displayOffset = "display_offset" case level = "level" case offset = "offset" case count = "count" } public init( hierarchy: Hierarchy, multiSessionKey: String? = nil, displayOffset: Int? = nil, level: UInt? = 0, offset: Int? = 0, count: UInt? = nil, ) { self.hierarchy = hierarchy self.multiSessionKey = multiSessionKey self.displayOffset = displayOffset self.level = level self.offset = offset self.count = count } } public struct LoadResponse: Codable, Sendable { public let items: [Item] let _offset: Int? public let list: List public var offset: Int { _offset ?? 0 } public enum CodingKeys: String, CodingKey { case items = "items" case _offset = "offset" case list = "list" } public enum DecodeError: Error { case nonSuccess case invalidBody(Moo.JsonBodyError) } public init(_ msg: Moo) throws(DecodeError) { guard msg.service == "Success" else { throw .nonSuccess } do { self = try msg.json(type: Self.self) } catch { throw .invalidBody(error) } } } } extension Moo { public init(load: BrowseService.LoadRequest) throws { try self.init(jsonBody: load, service: "\(BrowseService.id)/load") } }
-
-
-
@@ -1,93 +0,0 @@// Copyright 2025 Shota FUJI // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 import Foundation public struct ImageService { public static let id = "com.roonlabs.image:1" public typealias Key = String public enum Format: String, Codable { case jpeg = "image/jpeg" case png = "image/png" } public enum ScalingMethod: String, Codable { case fit, fill, stretch } public struct GetRequest { public let key: Key public let format: Format? public let scale: ScalingMethod? public let width: UInt? public let height: UInt? public init( key: Key, format: Format? = nil, scale: ScalingMethod? = nil, width: UInt? = nil, height: UInt? = nil ) { self.key = key self.format = format self.scale = scale self.width = width self.height = height } } } extension URL { public init?(roonImage: ImageService.GetRequest, host: String, port: UInt16) { var queries: [URLQueryItem] = [] if let scale = roonImage.scale { queries.append(.init(name: "scale", value: scale.rawValue)) } if let width = roonImage.width { queries.append(.init(name: "width", value: String(width, radix: 10))) } if let height = roonImage.height { queries.append(.init(name: "height", value: String(height, radix: 10))) } if let format = roonImage.format { queries.append(.init(name: "format", value: format.rawValue)) } var components = URLComponents() components.host = host components.port = Int(port) components.path = "/api/image/\(roonImage.key)" components.scheme = "http" if queries.count > 0 { components.queryItems = queries } if let url = components.url { self = url } else { return nil } } }
-
-
-
@@ -1,23 +0,0 @@// Copyright 2025 Shota FUJI // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 public struct PingService { public static let id = "com.roonlabs.ping:1" public struct Ping { public static let service = "\(id)/ping" } }
-
-
-
@@ -1,139 +0,0 @@// Copyright 2025 Shota FUJI // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 import Foundation public struct RegistryService { static let id = "com.roonlabs.registry:1" } // MARK: - Get Roon server information extension RegistryService { struct InfoRequest { init() {} } struct InfoResponse: Codable { let coreId: String let displayName: String let version: String enum CodingKeys: String, CodingKey { case coreId = "core_id" case displayName = "display_name" case version = "display_version" } enum DecodeError: Error { case invalidBody(Moo.JsonBodyError) } init(_ msg: Moo) throws(DecodeError) { do { self = try msg.json(type: Self.self) } catch { throw .invalidBody(error) } } } } extension Moo { init(info: RegistryService.InfoRequest) { self.init(service: "\(RegistryService.id)/info") } } // MARK: - Register Roon extension extension RegistryService { public struct RegisterRequest: Codable { public let id: String public let displayName: String public let version: String public let publisher: String public let email: String public let requiredServices: [String] public let optionalServices: [String] public let providedServices: [String] public let token: String? public init( id: String, displayName: String, version: String, publisher: String, email: String, requiredServices: [String] = [], optionalServices: [String] = [], providedServices: [String] = [PingService.id], token: String? = nil ) { self.id = id self.displayName = displayName self.version = version self.publisher = publisher self.email = email self.requiredServices = requiredServices self.optionalServices = optionalServices self.providedServices = providedServices self.token = token } public enum CodingKeys: String, CodingKey { case id = "extension_id" case displayName = "display_name" case version = "display_version" case publisher = "publisher" case email = "email" case requiredServices = "required_services" case optionalServices = "optional_services" case providedServices = "provided_services" case token = "token" } } public typealias Extension = RegisterRequest struct RegisterResponse: Decodable { let token: String enum DecodeError: Error { case unexpectedStatus(String) case invalidBody(Moo.JsonBodyError) } init(_ msg: Moo) throws(DecodeError) { guard msg.service == "Registered" else { throw .unexpectedStatus(msg.service) } do { self = try msg.json(type: Self.self) } catch { throw .invalidBody(error) } } } } extension Moo { init(register: RegistryService.RegisterRequest) throws { try self.init(jsonBody: register, service: "\(RegistryService.id)/register") } }
-
-
-
@@ -1,358 +0,0 @@// Copyright 2025 Shota FUJI // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 import Foundation public struct TransportService { public static let id = "com.roonlabs.transport:2" public struct SingleLineDisplay: Decodable, Hashable { public let line: String public enum CodingKeys: String, CodingKey { case line = "line1" } } public struct DoubleLineDisplay: Decodable, Hashable { public let line1: String public let line2: String? } public struct TripleLineDisplay: Decodable, Hashable { public let line1: String public let line2: String? public let line3: String? } public struct NowPlaying: Decodable, Hashable { public let seekPosition: UInt64? public let length: UInt64? public let imageKey: String? public let singleLine: SingleLineDisplay public let doubleLine: DoubleLineDisplay public let tripleLine: TripleLineDisplay public enum CodingKeys: String, CodingKey { case seekPosition = "seek_position" case length = "length" case imageKey = "image_key" case singleLine = "one_line" case doubleLine = "two_line" case tripleLine = "three_line" } } public enum PlaybackState: String, Decodable { case playing case paused case loading case stopped } public struct OutputVolume: Decodable, Hashable { public let type: String public let min: Float64? public let max: Float64? public let value: Float64? public let step: Float64? public let isMuted: Bool? public enum CodingKeys: String, CodingKey { case type = "type" case min = "min" case max = "max" case value = "value" case step = "step" case isMuted = "is_muted" } } public struct Output: Decodable, Identifiable, Hashable { public let id: String public let displayName: String public let volume: OutputVolume? public enum CodingKeys: String, CodingKey { case id = "output_id" case displayName = "display_name" case volume = "volume" } } public struct Zone: Decodable, Identifiable, Hashable { public let id: String public let displayName: String public let outputs: [Output] public let nowPlaying: NowPlaying? public let state: PlaybackState public let isPreviousAllowed: Bool? public let isNextAllowed: Bool? public let isPauseAllowed: Bool? public let isPlayAllowed: Bool? public let isSeekAllowed: Bool? public enum CodingKeys: String, CodingKey { case id = "zone_id" case displayName = "display_name" case outputs = "outputs" case nowPlaying = "now_playing" case state = "state" case isPreviousAllowed = "is_previous_allowed" case isNextAllowed = "is_next_allowed" case isPauseAllowed = "is_pause_allowed" case isPlayAllowed = "is_play_allowed" case isSeekAllowed = "is_seek_allowed" } } public struct SeekChangeEvent: Decodable { public let zoneID: String public let seekPosition: UInt64? public enum CodingKeys: String, CodingKey { case zoneID = "zone_id" case seekPosition = "seek_position" } } public struct ZoneChangeEvent: Decodable { private let _removedZoneIDs: [String]? private let _addedZones: [Zone]? private let _changedZones: [Zone]? private let _seekChanges: [SeekChangeEvent]? public var removedZoneIDs: [String] { _removedZoneIDs ?? [] } public var addedZones: [Zone] { _addedZones ?? [] } public var changedZones: [Zone] { _changedZones ?? [] } public var seekChanges: [SeekChangeEvent] { _seekChanges ?? [] } public enum CodingKeys: String, CodingKey { case _removedZoneIDs = "zones_removed" case _addedZones = "zones_added" case _changedZones = "zones_changed" case _seekChanges = "zones_seek_changed" } public init?(_ msg: Moo) { do { self = try msg.json(type: Self.self) } catch { return nil } } } } // MARK: - Subscribe changes to zone extension TransportService { public struct SubscribeZoneChangesRequest: Encodable { public let subscriptionID: String public init(subscriptionID: String) { self.subscriptionID = subscriptionID } public init(subscriptionID: any BinaryInteger) { self.subscriptionID = String(subscriptionID, radix: 10) } public enum CodingKeys: String, CodingKey { case subscriptionID = "subscription_key" } } public struct SubscribeZoneChangesResponse: Decodable { public enum DecodeError: Error { case unexpectedStatus(String) case bodyError(Moo.JsonBodyError) } public let zones: [Zone] public init(_ msg: Moo) throws(DecodeError) { guard msg.service == "Subscribed" else { throw .unexpectedStatus(msg.service) } do { self = try msg.json(type: Self.self) } catch { throw .bodyError(error) } } } } extension Moo { public init( subscribeZoneChange: TransportService.SubscribeZoneChangesRequest ) throws { try self.init( jsonBody: subscribeZoneChange, service: "\(TransportService.id)/subscribe_zones" ) } } // MARK: - Send playback control extension TransportService { public enum ControlAction: String, Codable { case play, pause, playpause, stop, previous, next } public struct ControlRequest: Codable { public let zoneOrOutputID: String public let control: ControlAction public enum CodingKeys: String, CodingKey { case zoneOrOutputID = "zone_or_output_id" case control = "control" } public init(zoneID: String, control: ControlAction) { self.zoneOrOutputID = zoneID self.control = control } } public struct ControlResponse: Codable { public enum DecodeError: Error { case nonSuccess } public init(_ msg: Moo) throws(DecodeError) { guard msg.service == "Success" else { throw .nonSuccess } } } } extension Moo { public init(control: TransportService.ControlRequest) throws { try self.init(jsonBody: control, service: "\(TransportService.id)/control") } } // MARK: - Seek current playing song extension TransportService { public enum SeekMode: String, Codable { case relative, absolute } public struct SeekRequest: Codable { public let zoneOrOutputID: String public let mode: SeekMode public let seconds: Int64 public enum CodingKeys: String, CodingKey { case zoneOrOutputID = "zone_or_output_id" case mode = "how" case seconds = "seconds" } public init(zoneID: String, seconds: Int64, mode: SeekMode = .absolute) { self.zoneOrOutputID = zoneID self.mode = mode self.seconds = seconds } } public struct SeekResponse: Codable { public enum DecodeError: Error { case nonSuccess } public init(_ msg: Moo) throws(DecodeError) { guard msg.service == "Success" else { throw .nonSuccess } } } } extension Moo { public init(seek: TransportService.SeekRequest) throws { try self.init(jsonBody: seek, service: "\(TransportService.id)/seek") } } // MARK: - Change output volume extension TransportService { public enum ChangeVolumeMode: String, Codable { case absolute, relative case relativeStep = "relative_step" } public struct ChangeVolumeRequest: Codable { public let outputID: String public let mode: ChangeVolumeMode public let value: Float64 public enum CodingKeys: String, CodingKey { case outputID = "output_id" case mode = "how" case value = "value" } public init( outputID: String, value: Float64, mode: ChangeVolumeMode = .absolute ) { self.outputID = outputID self.value = value self.mode = mode } } public struct ChangeVolumeResponse: Codable { public enum DecodeError: Error { case nonSuccess } public init(_ msg: Moo) throws(DecodeError) { guard msg.service == "Success" else { throw .nonSuccess } } } } extension Moo { public init(changeVolume: TransportService.ChangeVolumeRequest) throws { try self.init( jsonBody: changeVolume, service: "\(TransportService.id)/change_volume" ) } }
-
-
macos/RoonKit/Sources/RoonKit/Sood.swift (deleted)
-
@@ -1,250 +0,0 @@// Copyright 2025 Shota FUJI // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 import Foundation enum SoodDecodeError: Error { case signatureMismatch case missingMessageKind case propertyKeySizeIsZero case incompletePropertyKey case missingPropertyValueSize case incompletePropertyValue case invalidPropertyKey case invalidPropertyValue } enum SoodMessageKind: Equatable, LosslessStringConvertible { /// Unknown, or unsupported message kind. Sorely exists for forward compatibility. case unknown(String) /// Discovery query. Client (Roon Extension) sends this via UDP multicast and/or broadcast. case query /// Response to a query. Roon Server sends this in response to a query SOOD message. case response var description: String { switch self { case .query: return "Q" case .response: return "R" case .unknown(let original): return original } } init(_ from: String) { switch from { case "R": self = .response case "Q": self = .query default: self = .unknown(from) } } func data() -> Data { return String(self).data(using: .utf8)! } } /// SOOD message is a binary data format used for Roon Server Discovery, a process for finding /// Roon Servers in a network. SOOD messages are sent over UDP via multicast and broadcast. struct Sood { private static let signature = "SOOD\u{2}" let kind: SoodMessageKind let properties: [String: String] init(kind: SoodMessageKind, properties: [String: String] = [:]) { self.kind = kind self.properties = properties } init?(_ from: String) { try? self.init(from: from.data(using: .utf8)!) } private enum PropertyParseState { case keySize case key(read: [UInt8], remainingBytes: UInt8) case valueSizeHi(key: String) case valueSizeLo(key: String, hi: UInt8) case value(key: String, read: [UInt8], remainingBytes: UInt16) } private enum ParseState { case signature(expecting: Character, remaining: Substring) case kind case properties( SoodMessageKind, parsing: PropertyParseState, parsed: [String: String] ) } init(from: Data) throws(SoodDecodeError) { var state: ParseState = .signature( expecting: Sood.signature.first!, remaining: Sood.signature.dropFirst() ) for byte in from { switch state { case .signature(let expecting, let remaining): if byte != expecting.asciiValue { throw .signatureMismatch } guard let next = remaining.first else { state = .kind break } state = .signature(expecting: next, remaining: remaining.dropFirst()) break case .kind: guard let char = String(bytes: [byte], encoding: .ascii) else { throw .missingMessageKind } state = .properties(.init(char), parsing: .keySize, parsed: [:]) break case .properties(let kind, let parsing, let parsed): switch parsing { case .keySize: if byte == 0 { throw .propertyKeySizeIsZero } state = .properties( kind, parsing: .key(read: [], remainingBytes: byte), parsed: parsed ) break case .key(let read, let remainingBytes): let read = read + [byte] if remainingBytes > 1 { state = .properties( kind, parsing: .key(read: read, remainingBytes: remainingBytes - 1), parsed: parsed ) break } guard let key = String(bytes: read, encoding: .utf8) else { throw .invalidPropertyKey } state = .properties( kind, parsing: .valueSizeHi(key: key), parsed: parsed ) break case .valueSizeHi(let key): state = .properties( kind, parsing: .valueSizeLo(key: key, hi: byte), parsed: parsed ) break case .valueSizeLo(let key, let hi): let hi = UInt16(hi) let lo = UInt16(byte) let size = UInt16(bigEndian: (hi.bigEndian << 8) | lo.bigEndian) state = .properties( kind, parsing: .value(key: key, read: [], remainingBytes: size), parsed: parsed ) break case .value(let key, let read, let remainingBytes): let read = read + [byte] if remainingBytes > 1 { state = .properties( kind, parsing: .value( key: key, read: read, remainingBytes: remainingBytes - 1 ), parsed: parsed ) break } guard let value = String(bytes: read, encoding: .utf8) else { throw .invalidPropertyValue } var parsed = parsed _ = parsed.updateValue(value, forKey: key) state = .properties(kind, parsing: .keySize, parsed: parsed) break } } } switch state { case .signature(_, _): throw .signatureMismatch case .kind: throw .missingMessageKind case .properties(let kind, parsing: .keySize, let parsed): self.kind = kind self.properties = parsed return case .properties(_, parsing: .key(_, _), parsed: _): throw .invalidPropertyKey case .properties(_, parsing: .valueSizeHi(_), parsed: _): throw .missingPropertyValueSize case .properties(_, parsing: .valueSizeLo(_, _), parsed: _): throw .missingPropertyValueSize case .properties(_, parsing: .value(_, _, _), parsed: _): throw .incompletePropertyValue } } func data() -> Data { var data = Sood.signature.data(using: .utf8)! data.append(self.kind.data()) for (key, value) in self.properties { let key = key.data(using: .utf8)! data.append(contentsOf: [UInt8(key.count)]) data.append(key) let value = value.data(using: .utf8)! let hi = UInt8((UInt16(value.count).bigEndian & 0b11111111).littleEndian) let lo = UInt8( ((UInt16(value.count).bigEndian & 0b11111111_00000000) >> 8) .littleEndian ) data.append(contentsOf: [hi, lo]) data.append(value) } return data } }
-
-
-
@@ -1,123 +0,0 @@// Copyright 2025 Shota FUJI // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 import Testing @testable import RoonKit @Suite("MOO message decode tests") struct MooMessageDecodeTests { @Test func acceptsMetadataOnlyMessage() throws { let msg = try Moo(from: "MOO/1 REQUEST foo/bar\n") #expect(msg.schemaVersion == 1) #expect(msg.verb == "REQUEST") #expect(msg.service == "foo/bar") #expect(msg.headers == [:]) #expect(msg.body == .none) } @Test func rejectsEmptyMessage() { #expect(throws: MooMessageDecodeError.signatureMismatch) { let _ = try Moo(from: "") } } @Test func rejectsInvalidSignature() { #expect(throws: MooMessageDecodeError.signatureMismatch) { let _ = try Moo(from: "MOO1 REQUEST foo/bar\n") } } @Test func rejectsInvalidVersion() { #expect(throws: MooMessageDecodeError.invalidVersion) { let _ = try Moo(from: "MOO/1.0 REQUEST foo/bar\n") } #expect(throws: MooMessageDecodeError.invalidVersion) { let _ = try Moo(from: "MOO/-1 REQUEST foo/bar\n") } #expect(throws: MooMessageDecodeError.invalidVersion) { let _ = try Moo(from: "MOO/ REQUEST foo/bar\n") } #expect(throws: MooMessageDecodeError.invalidVersion) { let _ = try Moo(from: "MOO/0x1 REQUEST foo/bar\n") } #expect(throws: MooMessageDecodeError.invalidVersion) { let _ = try Moo(from: "MOO/one REQUEST foo/bar\n") } } @Test func acceptsMessageWithoutBody() throws { let msg = try Moo( from: "MOO/1 REQUEST foo/bar\nRequest-Id: 5\nAccept-Encoding:application/json;charset=utf-8\n\n" ) #expect(msg.schemaVersion == 1) #expect(msg.verb == "REQUEST") #expect(msg.service == "foo/bar") #expect(msg.headers.count == 2) #expect(msg.headers["Request-Id"] == .some("5")) #expect( msg.headers["Accept-Encoding"] == .some("application/json;charset=utf-8") ) #expect(msg.body == .none) } @Test func acceptsFullMessage() throws { let msg = try Moo( from: "MOO/1 REQUEST foo/bar\nRequest-Id: 5\nContent-Type:application/json;charset=utf-8\n\n{\"foo\":\"bar\"}" ) #expect(msg.schemaVersion == 1) #expect(msg.verb == "REQUEST") #expect(msg.service == "foo/bar") #expect(msg.headers.count == 2) #expect(msg.headers["Request-Id"] == .some("5")) #expect( msg.headers["Content-Type"] == .some("application/json;charset=utf-8") ) #expect(msg.body == .some("{\"foo\":\"bar\"}")) } @Test func encodeThenDecode() { let msg = Moo( schemaVersion: 2, verb: "OPTIONS", service: "foo/bar", headers: ["Language": "Esperanto"], body: "FOO BAR".data(using: .utf8)! ) let encoded = String(msg) guard let decoded = Moo(encoded) else { #expect(Bool(false), "Unable to encode") return } #expect(decoded.schemaVersion == 2) #expect(decoded.verb == "OPTIONS") #expect(decoded.service == "foo/bar") #expect(decoded.headers.count == 1) #expect(decoded.headers["Language"] == .some("Esperanto")) #expect(decoded.body == .some("FOO BAR")) } }
-
-
-
@@ -1,67 +0,0 @@// Copyright 2025 Shota FUJI // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 import Testing @testable import RoonKit @Suite("Discovery response parsing tests") struct ServerDiscoveryResponseTests { @Test func rejectsMissingProperties() { #expect( throws: ServerDiscoveryResponseParseError.missingProperty(.uniqueId) ) { let _ = try ServerDiscoveryResponse( from: "SOOD\u{2}R\u{3}foo\u{0}\u{3}bar".data(using: .utf8)! ) } } @Test func rejectsNonResponseMessage() { let msg = Sood( kind: .query, properties: [ "http_port": "9999", "name": "Foo", "display_version": "devel", "unique_id": "i-am-foo", ] ) #expect(throws: ServerDiscoveryResponseParseError.unexpectedMessageKind) { let _ = try ServerDiscoveryResponse(from: msg.data()) } } @Test func decode() throws { let msg = Sood( kind: .response, properties: [ "http_port": "9999", "name": "Foo", "display_version": "devel", "unique_id": "i-am-foo", ] ) let decoded = try ServerDiscoveryResponse(from: msg.data()) #expect(decoded.displayName == "Foo") #expect(decoded.version == "devel") #expect(decoded.uniqueId == "i-am-foo") #expect(decoded.httpPort == 9999) } }
-
-
-
@@ -1,59 +0,0 @@// Copyright 2025 Shota FUJI // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 import Foundation import Testing @testable import RoonKit @Suite("Subtitle parsing") struct BrowseServiceSubtitleParsingTests { @Test func fullLink() { let item = BrowseService.Item(title: "Foo", subtitle: "[[123456|Subtitle]]") #expect(item.correctedSubtitle == "Subtitle") } @Test func nonLink() { let item = BrowseService.Item(title: "Foo", subtitle: "[123456|Subtitle]") #expect(item.correctedSubtitle == "[123456|Subtitle]") } @Test func linkStartsInMiddle() { let item = BrowseService.Item(title: "Foo", subtitle: "foo [[123456|bar]]") #expect(item.correctedSubtitle == "foo bar") } @Test func linkEndsInMiddle() { let item = BrowseService.Item(title: "Foo", subtitle: "[[123456|foo]] bar") #expect(item.correctedSubtitle == "foo bar") } @Test func linkSurrounded() { let item = BrowseService.Item( title: "Foo", subtitle: "foo [[123456|bar]] baz" ) #expect(item.correctedSubtitle == "foo bar baz") } @Test func multipleLinks() { let item = BrowseService.Item( title: "Foo", subtitle: "[[1|foo]] [[2|bar]] [[3|baz]]" ) #expect(item.correctedSubtitle == "foo bar baz") } }
-
-
-
@@ -1,48 +0,0 @@// Copyright 2025 Shota FUJI // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 import Foundation import Testing @testable import RoonKit @Suite("ImageService URL construction tests") struct ImageServiceURLConstructionTests { @Test func buildsKeyOnlyURL() async throws { let url = URL(roonImage: .init(key: "foo"), host: "127.0.0.1", port: 8080)! #expect(url.absoluteString == "http://127.0.0.1:8080/api/image/foo") } @Test func buildsFullParameters() async throws { let url = URL( roonImage: .init( key: "foo", format: .png, scale: .fit, width: 100, height: 200, ), host: "127.0.0.1", port: 8080 )! #expect( url.absoluteString == "http://127.0.0.1:8080/api/image/foo?scale=fit&width=100&height=200&format=image/png" ) } }
-
-
-
@@ -1,88 +0,0 @@// Copyright 2025 Shota FUJI // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 import Testing @testable import RoonKit @Suite("SOOD message decode tests") struct SoodMessageDecodeTests { @Test func rejectsPngHeader() { let msg = "\u{89}PNG\r\n\u{1a}\n" #expect(throws: SoodDecodeError.signatureMismatch) { let _ = try Sood(from: msg.data(using: .utf8)!) } } @Test func rejectsNoSoodSignatureDelimiter() { let msg = "SOODR\u{2}foo" #expect(throws: SoodDecodeError.signatureMismatch) { let _ = try Sood(from: msg.data(using: .utf8)!) } } @Test func rejectsSoodMissingKind() { let msg = "SOOD\u{2}" #expect(throws: SoodDecodeError.missingMessageKind) { let _ = try Sood(from: msg.data(using: .utf8)!) } } @Test func acceptsUnknownSoodMessageKind() throws { let msg = try Sood(from: "SOOD\u{2}r".data(using: .utf8)!) #expect(msg.kind == .unknown("r")) } @Test func acceptsNonPrintableSoodMessageKind() throws { let msg = try Sood(from: "SOOD\u{2}\u{2}".data(using: .utf8)!) #expect(msg.kind == .unknown("\u{2}")) } @Test func acceptsSoodMessageWithoutProperties() throws { let msg = try Sood(from: "SOOD\u{2}Q".data(using: .utf8)!) #expect(msg.kind == .query) #expect(msg.properties.count == 0) } @Test func acceptsSoodMessageWithProperties() throws { let msg = try Sood( from: "SOOD\u{2}R\u{3}foo\u{0}\u{3}bar".data(using: .utf8)! ) #expect(msg.kind == .response) #expect(msg.properties.count == 1) #expect(msg.properties["foo"] == .some("bar")) } } @Suite("SOOD encode tests") struct SoodMessageEncodeTests { @Test func encodeAndDecode() throws { let original = Sood(kind: .response, properties: ["foo": "bar"]) let decoded = try Sood(from: original.data()) #expect(decoded.kind == .response) #expect(decoded.properties.count == 1) #expect(decoded.properties["foo"] == .some("bar")) } }
-
-
macos/swift-format.json (deleted)
-
@@ -1,7 +0,0 @@{ "version": 1, "lineLength": 100, "indentation": { "tabs": 1 } }
-
-
macos/swift-format.json.license (deleted)
-
@@ -1,21 +0,0 @@Copyright 2025 Shota FUJI Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. SPDX-License-Identifier: Apache-2.0 === Config file for swift-format tool. https://github.com/swiftlang/swift-format/blob/dffadd6073bc1af2abb0a743301c5bdec27a9829/Documentation/Configuration.md
-