diff --git a/.github/ISSUE_TEMPLATE/---bug-report.md b/.github/ISSUE_TEMPLATE/---bug-report.md index 2f74e3490..5d04f87ff 100644 --- a/.github/ISSUE_TEMPLATE/---bug-report.md +++ b/.github/ISSUE_TEMPLATE/---bug-report.md @@ -2,12 +2,11 @@ name: "\U0001F41B Bug report" about: Something's not working as expected title: '' -labels: support +labels: bug assignees: '' --- - - + ### Checklist diff --git a/.github/ISSUE_TEMPLATE/---feature-request.md b/.github/ISSUE_TEMPLATE/---feature-request.md deleted file mode 100644 index 1bb59d770..000000000 --- a/.github/ISSUE_TEMPLATE/---feature-request.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -name: "\U0001F680 Feature request" -about: Suggest an idea for this project -title: '' -labels: feature -assignees: '' - ---- - - - - - - - - diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 000000000..a6024b37e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: Blink Community Support + url: https://github.com/blinksh/blink/discussions + about: Please ask and answer questions here. diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 241566fa7..ed073e408 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -5,8 +5,8 @@ on: [push] jobs: build: - # runs-on: macOS-latest - runs-on: macos-13 + runs-on: macOS-latest + #runs-on: macos-13 steps: # - uses: swift-actions/setup-swift@v1 @@ -30,23 +30,27 @@ jobs: if: steps.cache.outputs.cache-hit != 'true' run: ./get_frameworks.sh + - name: get resources + if: steps.cache.outputs.cache-hit != 'true' + run: ./get_resources.sh + - name: copy xcconfig run: cp template_setup.xcconfig developer_setup.xcconfig - name: BlinkTests run: xcodebuild -project Blink.xcodeproj -scheme BlinkTests -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPad Air (5th generation)' test | xcpretty - - name: actual build - run: set -o pipefail && xcodebuild archive -project Blink.xcodeproj -scheme Blink -sdk iphoneos -configuration Debug clean build IPHONEOS_DEPLOYMENT_TARGET='16.1' CODE_SIGN_IDENTITY='' CODE_SIGNING_REQUIRED=NO CODE_SIGNING_ALLOWED=NO ONLY_ACTIVE_ARCH=NO | tee build.log | xcpretty - - - name: generate unsigned debug ipa - run: | - cd ~ - mkdir Payload - mv ~/Library/Developer/Xcode/Archives/*/*/Products/Applications/Blink.app ~/Payload/ - zip -r Blink.ipa Payload - - name: upload ipa - uses: actions/upload-artifact@v2.2.0 - with: - name: 'BlinkShell' - path: ~/Blink.ipa + # - name: actual build + # run: set -o pipefail && xcodebuild archive -project Blink.xcodeproj -scheme Blink -sdk iphoneos -configuration Debug clean build IPHONEOS_DEPLOYMENT_TARGET='16.1' CODE_SIGN_IDENTITY='' CODE_SIGNING_REQUIRED=NO CODE_SIGNING_ALLOWED=NO ONLY_ACTIVE_ARCH=NO | tee build.log | xcpretty + + # - name: generate unsigned debug ipa + # run: | + # cd ~ + # mkdir Payload + # mv ~/Library/Developer/Xcode/Archives/*/*/Products/Applications/Blink.app ~/Payload/ + # zip -r Blink.ipa Payload + # - name: upload ipa + # uses: actions/upload-artifact@v2.2.0 + # with: + # name: 'BlinkShell' + # path: ~/Blink.ipa diff --git a/Blink.xcodeproj/project.pbxproj b/Blink.xcodeproj/project.pbxproj index 568a080fb..5b66601b2 100644 --- a/Blink.xcodeproj/project.pbxproj +++ b/Blink.xcodeproj/project.pbxproj @@ -75,11 +75,7 @@ 803B99E3258381B200DC99C8 /* SettingsHostingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 803B99E2258381B200DC99C8 /* SettingsHostingController.swift */; }; 85A34307200A837A009324F1 /* webfontloader.js in Resources */ = {isa = PBXBuildFile; fileRef = 85A34303200A837A009324F1 /* webfontloader.js */; }; 85A34309200A8FAF009324F1 /* term.css in Resources */ = {isa = PBXBuildFile; fileRef = 85A34308200A8FAF009324F1 /* term.css */; }; - 98271253262E4BDB00F883FA /* FileProviderExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98271252262E4BDB00F883FA /* FileProviderExtension.swift */; }; - 98271257262E4BDB00F883FA /* FileProviderEnumerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98271256262E4BDB00F883FA /* FileProviderEnumerator.swift */; }; - 9827126B262E4BDB00F883FA /* BlinkFileProvider.appex in Embed PlugIns */ = {isa = PBXBuildFile; fileRef = 98271250262E4BDB00F883FA /* BlinkFileProvider.appex */; platformFilter = ios; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; - 98E7D0BE2638B46400758CF9 /* BlinkItemReference.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98E7D0BD2638B46400758CF9 /* BlinkItemReference.swift */; }; - 98E7D0E4263971C000758CF9 /* BlinkFiles.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 07FABBAF25C9AECF00E1CC2C /* BlinkFiles.framework */; }; + 9827126B262E4BDB00F883FA /* BlinkFileProviderExtension.appex in Embed PlugIns */ = {isa = PBXBuildFile; fileRef = 98271250262E4BDB00F883FA /* BlinkFileProviderExtension.appex */; platformFilter = ios; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; B700AED81DD0F2C200100EBF /* CloudKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B700AED71DD0F2C200100EBF /* CloudKit.framework */; }; B75112CD1DE4A7B10040C693 /* BKiCloudConfigurationViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = B75112CC1DE4A7B10040C693 /* BKiCloudConfigurationViewController.m */; }; B752EE2B1DFEF19D00E305C8 /* BKUserConfigurationManager.m in Sources */ = {isa = PBXBuildFile; fileRef = B752EE2A1DFEF19D00E305C8 /* BKUserConfigurationManager.m */; }; @@ -92,9 +88,16 @@ BD028AF32A8EC509002F5F54 /* TrialSupportView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD028AF22A8EC509002F5F54 /* TrialSupportView.swift */; }; BD11E9E6270CD0FD003EA5AE /* openssl.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = D2F64C9425CA99AD00F2225D /* openssl.xcframework */; }; BD1758AC26EA8C5400AEC545 /* MenuController.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD1758AB26EA8C5400AEC545 /* MenuController.swift */; }; - BD2E27B529BAA8DA003AF1DA /* ReplaySubject.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD2E27B429BAA8DA003AF1DA /* ReplaySubject.swift */; }; + BD19DB412B056E9C003A4367 /* SSHCommandTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD19DB402B056E9C003A4367 /* SSHCommandTest.swift */; }; + BD23B6E12CB0585B0041C38D /* ios_patches.m in Sources */ = {isa = PBXBuildFile; fileRef = BD23B6E02CB0585B0041C38D /* ios_patches.m */; }; + BD2C754E2D75FA85007BFE77 /* Walkthrough.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD2C754D2D75FA7F007BFE77 /* Walkthrough.swift */; }; + BD33F7822AAA426D00CD16EE /* MoshBootstrap.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD33F7802AAA426D00CD16EE /* MoshBootstrap.swift */; }; + BD33F7872AAA7C4300CD16EE /* MoshBootstrapTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD33F7862AAA7C4300CD16EE /* MoshBootstrapTests.swift */; }; BD3E1E53278D190500333C44 /* Archive.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD3E1E4F278D190500333C44 /* Archive.swift */; }; - BD44DCE626D6BEAC00054338 /* BlinkItemIdentifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD44DCE526D6BEAC00054338 /* BlinkItemIdentifier.swift */; }; + BD4DBAFA2CADA82700538194 /* SQLite in Frameworks */ = {isa = PBXBuildFile; productRef = BD4DBAF92CADA82700538194 /* SQLite */; }; + BD4DBAFC2CADB3AF00538194 /* WorkingSetDatabase.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD4DBAFB2CADB3AF00538194 /* WorkingSetDatabase.swift */; }; + BD4F79F82CB4975E00FE67EA /* WorkingSetDatabaseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD4F79F72CB4975E00FE67EA /* WorkingSetDatabaseTests.swift */; }; + BD4F7A072CBC9FD300FE67EA /* FileTranslatorConnectionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD4F7A062CBC9FD300FE67EA /* FileTranslatorConnectionTests.swift */; }; BD67FC79272B30F300C1EE75 /* Messages.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD67FC78272B30F300C1EE75 /* Messages.swift */; }; BD67FC81272DF22700C1EE75 /* BlinkConfig.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = BDCB715E268E1577007D7047 /* BlinkConfig.framework */; }; BD67FC85272E3FF800C1EE75 /* SSH.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 07FABB8425C9AEC000E1CC2C /* SSH.framework */; }; @@ -108,12 +111,18 @@ BD792A512A3BDAC4009EE35F /* ZIPFoundation in Frameworks */ = {isa = PBXBuildFile; productRef = BD792A502A3BDAC4009EE35F /* ZIPFoundation */; }; BD7AFC4128E8E98C004BCDA4 /* Base32Kit in Frameworks */ = {isa = PBXBuildFile; productRef = BD7AFC4028E8E98C004BCDA4 /* Base32Kit */; }; BD7AFC4328E8E9A9004BCDA4 /* Base32Kit in Frameworks */ = {isa = PBXBuildFile; productRef = BD7AFC4228E8E9A9004BCDA4 /* Base32Kit */; }; + BD801D202C9352E800556515 /* curl_ios.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = BDF2B8D62BC481F000B9C7EA /* curl_ios.xcframework */; }; + BD801D212C9352E800556515 /* curl_ios.xcframework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = BDF2B8D62BC481F000B9C7EA /* curl_ios.xcframework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; BD81522027387D1F002BB169 /* Certificates.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD81521C27387D1F002BB169 /* Certificates.swift */; }; BD81522D2739A91D002BB169 /* BlinkLogging.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD9EA20A271F62ED00874007 /* BlinkLogging.swift */; }; BD81522E2739A91D002BB169 /* Publisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD9EA20C271F664D00874007 /* Publisher.swift */; }; BD8152542743FF84002BB169 /* skstore.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD8152532743FF84002BB169 /* skstore.swift */; }; + BD818A052AAFC18400956488 /* mosh.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD818A042AAFC18400956488 /* mosh.swift */; }; + BD818A0C2AB120B800956488 /* MoshServerParams.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD818A0B2AB120B800956488 /* MoshServerParams.swift */; }; + BD818A132AB3865F00956488 /* MoshCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD818A122AB3865F00956488 /* MoshCommand.swift */; }; + BD818A152AB3A40100956488 /* MoshClientParams.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD818A142AB3A40100956488 /* MoshClientParams.swift */; }; BD835DD427A0BD19002C37D7 /* ReplaySubject.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD835DD027A0BD19002C37D7 /* ReplaySubject.swift */; }; - BD896F7B26CEAD37004313E6 /* FileTranslatorCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD896F7A26CEAD37004313E6 /* FileTranslatorCache.swift */; }; + BD88697B2CF4DDAD00F71119 /* 1810Migration.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD88697A2CF4DD9A00F71119 /* 1810Migration.swift */; }; BD8BBF5525F829B00084705F /* SEKeyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD8BBF0825F819970084705F /* SEKeyTests.swift */; }; BD8BBFB025F947710084705F /* Keys.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD8BBFAF25F947710084705F /* Keys.swift */; }; BD8BBFF826001B020084705F /* AgentConstraints.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD8BBFF426001B020084705F /* AgentConstraints.swift */; }; @@ -126,31 +135,46 @@ BD8D897325DC534F00E55D9E /* user_key in Resources */ = {isa = PBXBuildFile; fileRef = BD8D896F25DC534F00E55D9E /* user_key */; }; BD8D897425DC534F00E55D9E /* id_ecdsa.pub in Resources */ = {isa = PBXBuildFile; fileRef = BD8D897025DC534F00E55D9E /* id_ecdsa.pub */; }; BD8D897525DC534F00E55D9E /* user_key-cert.pub in Resources */ = {isa = PBXBuildFile; fileRef = BD8D897125DC534F00E55D9E /* user_key-cert.pub */; }; - BD8DB62A279B1EC800497C88 /* SSHClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD8DB629279B1EC800497C88 /* SSHClient.swift */; }; - BD8DB642279B2FA200497C88 /* SSHClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD8DB641279B2FA200497C88 /* SSHClient.swift */; }; BD8DB647279B512900497C88 /* CodeFileSystem.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD8DB645279B512900497C88 /* CodeFileSystem.swift */; }; BD8DB648279B512900497C88 /* CodeFileSystemService.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD8DB646279B512900497C88 /* CodeFileSystemService.swift */; }; BD90BE4A2A18466E00DA5686 /* AgentForwardPromptPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD90BE492A18466E00DA5686 /* AgentForwardPromptPickerView.swift */; }; + BD9513EB2B4F5A7A00A7BEBE /* BlinkFileProvider.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = BD9513E12B4F5A7A00A7BEBE /* BlinkFileProvider.framework */; }; + BD9513F22B4F5A7A00A7BEBE /* BlinkFileProviderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD9513F12B4F5A7A00A7BEBE /* BlinkFileProviderTests.swift */; }; + BD9513F32B4F5A7A00A7BEBE /* BlinkFileProvider.h in Headers */ = {isa = PBXBuildFile; fileRef = BD9513E32B4F5A7A00A7BEBE /* BlinkFileProvider.h */; settings = {ATTRIBUTES = (Public, ); }; }; + BD9514002B4F5B5600A7BEBE /* ReplaySubject.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD9513FF2B4F5B5600A7BEBE /* ReplaySubject.swift */; }; + BD95140A2B4F5B9400A7BEBE /* SSHClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD9514022B4F5B9400A7BEBE /* SSHClient.swift */; }; + BD95140C2B4F5B9400A7BEBE /* NSFileProviderError.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD9514042B4F5B9400A7BEBE /* NSFileProviderError.swift */; }; + BD9514112B4F5F0800A7BEBE /* BlinkConfig.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = BDCB715E268E1577007D7047 /* BlinkConfig.framework */; }; + BD9514152B4F5F0900A7BEBE /* BlinkFiles.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 07FABBAF25C9AECF00E1CC2C /* BlinkFiles.framework */; }; + BD9514192B4F5F0900A7BEBE /* SSH.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 07FABB8425C9AEC000E1CC2C /* SSH.framework */; }; + BD95141F2B4F5F4000A7BEBE /* main.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD95141E2B4F5F4000A7BEBE /* main.swift */; }; + BD9514202B4F600800A7BEBE /* BlinkFileProvider.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = BD9513E12B4F5A7A00A7BEBE /* BlinkFileProvider.framework */; }; + BD9514272B4F606500A7BEBE /* SSHClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD9514262B4F606500A7BEBE /* SSHClient.swift */; }; + BD9514282B4F60F600A7BEBE /* BlinkLogging.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD9EA20A271F62ED00874007 /* BlinkLogging.swift */; }; + BD9514292B4F60F600A7BEBE /* Publisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD9EA20C271F664D00874007 /* Publisher.swift */; }; + BD9514312B4F660B00A7BEBE /* FileProviderReplicatedExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD9514302B4F660B00A7BEBE /* FileProviderReplicatedExtension.swift */; }; + BD9514332B4F661600A7BEBE /* FileProviderItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD9514322B4F661600A7BEBE /* FileProviderItem.swift */; }; + BD9514352B4F662100A7BEBE /* FileProviderReplicatedEnumerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD9514342B4F662100A7BEBE /* FileProviderReplicatedEnumerator.swift */; }; BD98AC84260BD8DC00B4E6A1 /* SSHAgentAdd.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD98AC83260BD8DC00B4E6A1 /* SSHAgentAdd.swift */; }; - BD98AC95260BE20000B4E6A1 /* SSHAgentPool.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD98AC94260BE20000B4E6A1 /* SSHAgentPool.swift */; }; + BD98AC95260BE20000B4E6A1 /* SSHDefaultAgent.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD98AC94260BE20000B4E6A1 /* SSHDefaultAgent.swift */; }; BD9BF7E7262A6B0300B02074 /* SOCKS.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD9BF7E3262A6B0300B02074 /* SOCKS.swift */; }; BD9BF7E9262A6B0F00B02074 /* SOCKSTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD9BF7E8262A6B0F00B02074 /* SOCKSTests.swift */; }; - BD9EA1802718D6C400874007 /* NSFileProviderError.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD9EA17C2718D6C400874007 /* NSFileProviderError.swift */; }; BD9EA1CC2718E19000874007 /* DocumentActionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD9EA1CB2718E19000874007 /* DocumentActionViewController.swift */; }; BD9EA1CF2718E19000874007 /* MainInterface.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = BD9EA1CD2718E19000874007 /* MainInterface.storyboard */; }; - BD9EA1D32718E19000874007 /* BlinkFileProviderUI.appex in Embed PlugIns */ = {isa = PBXBuildFile; fileRef = BD9EA1C92718E19000874007 /* BlinkFileProviderUI.appex */; platformFilter = ios; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + BD9EA1D32718E19000874007 /* BlinkFileProviderExtensionUI.appex in Embed PlugIns */ = {isa = PBXBuildFile; fileRef = BD9EA1C92718E19000874007 /* BlinkFileProviderExtensionUI.appex */; platformFilter = ios; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; BD9EA1FE271A148700874007 /* Migrator.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD9EA1F9271A148700874007 /* Migrator.swift */; }; BD9EA1FF271A148700874007 /* 1400Migration.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD9EA1FD271A148700874007 /* 1400Migration.swift */; }; BD9EA203271A3EFE00874007 /* Media.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = BD9EA202271A3EFE00874007 /* Media.xcassets */; }; - BD9EA20B271F62ED00874007 /* BlinkLogging.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD9EA20A271F62ED00874007 /* BlinkLogging.swift */; }; - BD9EA20D271F664D00874007 /* Publisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD9EA20C271F664D00874007 /* Publisher.swift */; }; BD9EA211271F824500874007 /* BlinkLogging.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD9EA20A271F62ED00874007 /* BlinkLogging.swift */; }; BD9EA212271F824900874007 /* Publisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD9EA20C271F664D00874007 /* Publisher.swift */; }; BD9EA216271F83B400874007 /* BlinkLoggingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD9EA215271F83B400874007 /* BlinkLoggingTests.swift */; }; BD9EA217271F846100874007 /* BlinkLogging.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD9EA20A271F62ED00874007 /* BlinkLogging.swift */; }; BD9EA218271F846400874007 /* Publisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD9EA20C271F664D00874007 /* Publisher.swift */; }; BDACC7752A6F100D00D0B261 /* TrialNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDACC7742A6F100D00D0B261 /* TrialNotification.swift */; }; + BDB129F32D077EB3006970A1 /* BookmarkedLocationsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDB129F22D077EB3006970A1 /* BookmarkedLocationsManager.swift */; }; + BDB129FA2D08B4DE006970A1 /* BookmarkedLocationsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDB129F82D08B4DE006970A1 /* BookmarkedLocationsView.swift */; }; BDB72CB227A9C08500DCC446 /* StoreKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = BDB72CB127A9C08500DCC446 /* StoreKit.framework */; }; + BDBA46CD2CD2EDD900AB44A0 /* FileProviderReplicatedExtension+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDBA46CC2CD2EDD300AB44A0 /* FileProviderReplicatedExtension+Helpers.swift */; }; BDBFA30D2728914F00C77798 /* BlinkCode.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = BDBFA3052728914F00C77798 /* BlinkCode.framework */; }; BDBFA3122728914F00C77798 /* BlinkCodeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDBFA3112728914F00C77798 /* BlinkCodeTests.swift */; }; BDBFA3132728914F00C77798 /* BlinkCode.h in Headers */ = {isa = PBXBuildFile; fileRef = BDBFA3072728914F00C77798 /* BlinkCode.h */; settings = {ATTRIBUTES = (Public, ); }; }; @@ -173,7 +197,6 @@ BDCB7181268E15A2007D7047 /* BKPubKey.m in Sources */ = {isa = PBXBuildFile; fileRef = BDCB7174268E15A2007D7047 /* BKPubKey.m */; }; BDCB7182268E15A2007D7047 /* SEKey.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDCB7175268E15A2007D7047 /* SEKey.swift */; }; BDCB7183268E15A2007D7047 /* BKConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDCB7176268E15A2007D7047 /* BKConfig.swift */; }; - BDCB7184268E160F007D7047 /* BlinkConfig.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = BDCB715E268E1577007D7047 /* BlinkConfig.framework */; }; BDCB718D268E16CE007D7047 /* BlinkConfig.h in Headers */ = {isa = PBXBuildFile; fileRef = BDCB718C268E16CE007D7047 /* BlinkConfig.h */; settings = {ATTRIBUTES = (Public, ); }; }; BDCB718E268E173D007D7047 /* SSH.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 07FABB8425C9AEC000E1CC2C /* SSH.framework */; }; BDCB7194268E175A007D7047 /* SSHConfig in Frameworks */ = {isa = PBXBuildFile; productRef = BDCB7193268E175A007D7047 /* SSHConfig */; }; @@ -184,7 +207,19 @@ BDD6D149275951D900E76F1F /* BKGlobalSSHConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDD6D148275951D900E76F1F /* BKGlobalSSHConfig.swift */; }; BDE7125D2A141E3100164F70 /* SSHAgentUserPrompt.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDE7125C2A141E3100164F70 /* SSHAgentUserPrompt.swift */; }; BDE7C45C29DCAEFA005E033E /* FileLocationPathTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDE7C45B29DCAEFA005E033E /* FileLocationPathTests.swift */; }; - BDF471BA268CD17B00A7A41B /* SSH.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 07FABB8425C9AEC000E1CC2C /* SSH.framework */; }; + BDE84C3C2BAE335100457391 /* vim in Resources */ = {isa = PBXBuildFile; fileRef = BDE84C3B2BAE335100457391 /* vim */; }; + BDE84C512BB233FB00457391 /* bc_ios.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = BDE84C502BB233F100457391 /* bc_ios.xcframework */; }; + BDE84C522BB233FB00457391 /* bc_ios.xcframework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = BDE84C502BB233F100457391 /* bc_ios.xcframework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + BDECEA462CDE7544007BBCF6 /* BlinkFileProvider.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = BD9513E12B4F5A7A00A7BEBE /* BlinkFileProvider.framework */; platformFilter = ios; }; + BDECEA472CDE7544007BBCF6 /* BlinkFileProvider.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = BD9513E12B4F5A7A00A7BEBE /* BlinkFileProvider.framework */; platformFilter = ios; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + BDF041142CA363F0005B7138 /* FilesTranslatorConnection.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDF041132CA363F0005B7138 /* FilesTranslatorConnection.swift */; }; + BDF2B8D32BC480CC00B9C7EA /* vim.xcframework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = BDE84C772BB33AD700457391 /* vim.xcframework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + BDF2B8D52BC480D100B9C7EA /* xxd.xcframework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = BDE84C762BB33AB800457391 /* xxd.xcframework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + BDF2B8DD2BC48D2800B9C7EA /* LibSSH.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = D2334EC425C1C04700385378 /* LibSSH.xcframework */; }; + BDF2B8DF2BC48D2D00B9C7EA /* libssh2.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = D2F64C9525CA99AD00F2225D /* libssh2.xcframework */; }; + BDF40FEB2C14A6CE00DF41C1 /* AgentSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDF40FE92C14A6CE00DF41C1 /* AgentSettingsView.swift */; }; + BDFA97322D7F49EF0020BBF2 /* ConfettiSwiftUI in Frameworks */ = {isa = PBXBuildFile; productRef = BDFA97312D7F49EF0020BBF2 /* ConfettiSwiftUI */; }; + BDFA97352D959BC90020BBF2 /* TypingText.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDFA97342D959BC90020BBF2 /* TypingText.swift */; }; C94437571D8311960096F84E /* BKResource.m in Sources */ = {isa = PBXBuildFile; fileRef = C94437561D8311960096F84E /* BKResource.m */; }; C94437601D831CD30096F84E /* Themes in Resources */ = {isa = PBXBuildFile; fileRef = C944375F1D831CD30096F84E /* Themes */; }; C94437651D838ABF0096F84E /* Fonts in Resources */ = {isa = PBXBuildFile; fileRef = C94437641D838ABF0096F84E /* Fonts */; }; @@ -214,7 +249,6 @@ D218069C25CC27C100B98902 /* AppKitBridge.bundle in Embed PlugIns */ = {isa = PBXBuildFile; fileRef = D218068725CC277900B98902 /* AppKitBridge.bundle */; platformFilter = maccatalyst; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; D218072725CD499C00B98902 /* LibSSH.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = D2334EC425C1C04700385378 /* LibSSH.xcframework */; }; D218073825CD4AF400B98902 /* openssl.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = D2F64C9425CA99AD00F2225D /* openssl.xcframework */; }; - D218076325CD4FDC00B98902 /* LibSSH.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = D2334EC425C1C04700385378 /* LibSSH.xcframework */; }; D21A3FD721943BE200269705 /* dark-settings-ipad-29pt.png in Resources */ = {isa = PBXBuildFile; fileRef = D21A3FC621943BE200269705 /* dark-settings-ipad-29pt.png */; }; D21A3FD821943BE200269705 /* dark-notification-ipad-20pt.png in Resources */ = {isa = PBXBuildFile; fileRef = D21A3FC721943BE200269705 /* dark-notification-ipad-20pt.png */; }; D21A3FD921943BE200269705 /* dark-settings-iphone-29pt@3x.png in Resources */ = {isa = PBXBuildFile; fileRef = D21A3FC821943BE200269705 /* dark-settings-iphone-29pt@3x.png */; }; @@ -275,8 +309,6 @@ D2334EFB25C1C28F00385378 /* network_ios.xcframework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = D2334EC625C1C04700385378 /* network_ios.xcframework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; D2334F0025C1C28F00385378 /* shell.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = D2334EC925C1C04700385378 /* shell.xcframework */; }; D2334F0125C1C28F00385378 /* shell.xcframework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = D2334EC925C1C04700385378 /* shell.xcframework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; - D2334F0225C1C28F00385378 /* ssh_cmd.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = D2334EC825C1C04700385378 /* ssh_cmd.xcframework */; }; - D2334F0325C1C28F00385378 /* ssh_cmd.xcframework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = D2334EC825C1C04700385378 /* ssh_cmd.xcframework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; D2334F0425C1C28F00385378 /* tar.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = D2334ECD25C1C04700385378 /* tar.xcframework */; }; D2334F0525C1C28F00385378 /* tar.xcframework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = D2334ECD25C1C04700385378 /* tar.xcframework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; D2334F0625C1C28F00385378 /* text.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = D2334EC725C1C04700385378 /* text.xcframework */; }; @@ -292,11 +324,9 @@ D23EA945260379EB00BCF1FF /* KeyListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D23EA944260379EB00BCF1FF /* KeyListView.swift */; }; D23EA9592604CB4C00BCF1FF /* FixedTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = D23EA9582604CB4B00BCF1FF /* FixedTextField.swift */; }; D23FFC6A261C2D46003E9227 /* template_setup.xcconfig in Resources */ = {isa = PBXBuildFile; fileRef = D23FFC69261C2D46003E9227 /* template_setup.xcconfig */; }; - D240806120BC8DF800F30099 /* tool_main.c in Sources */ = {isa = PBXBuildFile; fileRef = D240806020BC8DF800F30099 /* tool_main.c */; settings = {COMPILER_FLAGS = "-DHAVE_CONFIG_H -I Frameworks/ios_system/curl/curl/include/ -I Frameworks/ios_system/curl/curl/lib/ -I Frameworks/ios_system/curl/config_iphone/ -DBUILDING_LIBCURL -I Settings/Model/ -I Frameworks/libssh2.framework/Headers/ -DBLINKSHELL -I Frameworks/ios_system/"; }; }; D241CBD023040734003D64A5 /* KBKeyViewFlexible.swift in Sources */ = {isa = PBXBuildFile; fileRef = D241CBBE23040732003D64A5 /* KBKeyViewFlexible.swift */; }; D241CBD123040734003D64A5 /* KBKeyViewArrows.swift in Sources */ = {isa = PBXBuildFile; fileRef = D241CBBF23040732003D64A5 /* KBKeyViewArrows.swift */; }; D241CBD223040734003D64A5 /* KBDevice.swift in Sources */ = {isa = PBXBuildFile; fileRef = D241CBC023040733003D64A5 /* KBDevice.swift */; }; - D241CBD323040734003D64A5 /* KBSound.swift in Sources */ = {isa = PBXBuildFile; fileRef = D241CBC123040733003D64A5 /* KBSound.swift */; }; D241CBD423040734003D64A5 /* KBView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D241CBC223040733003D64A5 /* KBView.swift */; }; D241CBD523040734003D64A5 /* KBKey.swift in Sources */ = {isa = PBXBuildFile; fileRef = D241CBC323040733003D64A5 /* KBKey.swift */; }; D241CBD623040734003D64A5 /* KBSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = D241CBC423040733003D64A5 /* KBSection.swift */; }; @@ -327,12 +357,10 @@ D25DE9C12939EB36008246EB /* NonStdIO+ArgumentParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = D25DE9BE2939EB36008246EB /* NonStdIO+ArgumentParser.swift */; }; D25DE9C22939EB36008246EB /* NonStdIO+Spinner.swift in Sources */ = {isa = PBXBuildFile; fileRef = D25DE9BF2939EB36008246EB /* NonStdIO+Spinner.swift */; }; D263A7682AA9AFE7001C6CFC /* device_info.m in Sources */ = {isa = PBXBuildFile; fileRef = D263A7672AA9AFE7001C6CFC /* device_info.m */; }; - D264D2AD28F81DD4002B1B14 /* EarlyFeaturesAccessLetterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D264D2AC28F81DD4002B1B14 /* EarlyFeaturesAccessLetterView.swift */; }; D264D2B228F84592002B1B14 /* GridView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D264D2AF28F84592002B1B14 /* GridView.swift */; }; D264D2B328F84592002B1B14 /* UnavailErrorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D264D2B028F84592002B1B14 /* UnavailErrorView.swift */; }; D264D2B428F84592002B1B14 /* Models.swift in Sources */ = {isa = PBXBuildFile; fileRef = D264D2B128F84592002B1B14 /* Models.swift */; }; D264D2B628F84724002B1B14 /* whatsnew.m in Sources */ = {isa = PBXBuildFile; fileRef = D264D2B528F84724002B1B14 /* whatsnew.m */; }; - D264D2B828F96C66002B1B14 /* WhatsNewSceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D264D2B728F96C66002B1B14 /* WhatsNewSceneDelegate.swift */; }; D265FBC52317E5090017EAC4 /* SessionParamsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D265FBC42317E5090017EAC4 /* SessionParamsTests.swift */; }; D265FBC62317E54C0017EAC4 /* SessionParams.swift in Sources */ = {isa = PBXBuildFile; fileRef = D248E67D22DE14100057FE67 /* SessionParams.swift */; }; D265FBC9231905AC0017EAC4 /* NSCoder+CodingKey.swift in Sources */ = {isa = PBXBuildFile; fileRef = D265FBC8231905AC0017EAC4 /* NSCoder+CodingKey.swift */; }; @@ -379,10 +407,7 @@ D2AD8E7427A2BAFA00DED28D /* EntitlementsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2AD8E6527A2BAFA00DED28D /* EntitlementsManager.swift */; }; D2AD8E7527A2BAFA00DED28D /* PurchasesUserModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2AD8E6627A2BAFA00DED28D /* PurchasesUserModel.swift */; }; D2AD8E7627A2BAFA00DED28D /* Purchases.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2AD8E6727A2BAFA00DED28D /* Purchases.swift */; }; - D2AD8E7C27A2BAFA00DED28D /* PurchasePageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2AD8E6E27A2BAFA00DED28D /* PurchasePageView.swift */; }; - D2AD8E7D27A2BAFA00DED28D /* CheckmarkRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2AD8E6F27A2BAFA00DED28D /* CheckmarkRow.swift */; }; D2AD8E8427A2C80C00DED28D /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2AD8E8327A2C80C00DED28D /* SettingsView.swift */; }; - D2AD8E8927A2C81900DED28D /* ExplanationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2AD8E8727A2C81900DED28D /* ExplanationView.swift */; }; D2AD9ADE22DB80DE00861F66 /* SessionRegistry.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2AD9ADD22DB80DE00861F66 /* SessionRegistry.swift */; }; D2AE682828D05FD0003E4338 /* WebAuthnKey.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2AE682728D05FD0003E4338 /* WebAuthnKey.swift */; }; D2AE682A28D06076003E4338 /* SwiftCBOR in Frameworks */ = {isa = PBXBuildFile; productRef = D2AE682928D06076003E4338 /* SwiftCBOR */; }; @@ -440,14 +465,12 @@ D2DE0DCF260323F400A69B6F /* id_ed25519-passphrase in Resources */ = {isa = PBXBuildFile; fileRef = D2DE0DCD260323F400A69B6F /* id_ed25519-passphrase */; }; D2DE0DD0260323F400A69B6F /* id_ed25519-passphrase.pub in Resources */ = {isa = PBXBuildFile; fileRef = D2DE0DCE260323F400A69B6F /* id_ed25519-passphrase.pub */; }; D2DE0DDE260331F300A69B6F /* NewKeyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2DE0DDD260331F300A69B6F /* NewKeyView.swift */; }; - D2DE9CA626EA1CC000A0B29C /* BKMiniLog.m in Sources */ = {isa = PBXBuildFile; fileRef = D22A353726C1474200943C71 /* BKMiniLog.m */; }; D2E5454527C4C83C002635A2 /* FeedbackView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2E5454427C4C83C002635A2 /* FeedbackView.swift */; }; D2E5454A27C4D422002635A2 /* SupportView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2E5454927C4D422002635A2 /* SupportView.swift */; }; D2E66D142695B100007D42AA /* HostListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2E66D132695B100007D42AA /* HostListView.swift */; }; D2EC7B4C25DBC922008B6B3C /* XCTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2EC7B4B25DBC922008B6B3C /* XCTestCase.swift */; }; D2ECBF4929645814004E95C4 /* BuildApi.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2ECBF4829645814004E95C4 /* BuildApi.swift */; }; D2ED4A6F239BB12E000DC67F /* KeyCaptureView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2ED4A6E239BB12E000DC67F /* KeyCaptureView.swift */; }; - D2EFE1F520B7FAFC0087888B /* link_files.m in Sources */ = {isa = PBXBuildFile; fileRef = D2EFE1EE20B7FAFC0087888B /* link_files.m */; }; D2F30EAD205009CD008C5F35 /* base64js.min.js in Resources */ = {isa = PBXBuildFile; fileRef = D2F30EAC205009CD008C5F35 /* base64js.min.js */; }; D2F330CA20A6CB840074ADD7 /* help.m in Sources */ = {isa = PBXBuildFile; fileRef = D2F330C920A6CB840074ADD7 /* help.m */; }; D2F330CC20A6D98C0074ADD7 /* config.m in Sources */ = {isa = PBXBuildFile; fileRef = D2F330CB20A6D98C0074ADD7 /* config.m */; }; @@ -455,9 +478,6 @@ D2F330D420A6F1DF0074ADD7 /* clear.m in Sources */ = {isa = PBXBuildFile; fileRef = D2F330D320A6F1DF0074ADD7 /* clear.m */; }; D2F330D620A6F4F50074ADD7 /* history.m in Sources */ = {isa = PBXBuildFile; fileRef = D2F330D520A6F4F50074ADD7 /* history.m */; }; D2F330DA20A7127B0074ADD7 /* open.m in Sources */ = {isa = PBXBuildFile; fileRef = D2F330D920A7127B0074ADD7 /* open.m */; }; - D2F64CA925CAA92700F2225D /* curl_ios_static.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D2E4F92E20B2BB4500B30F7B /* curl_ios_static.framework */; }; - D2F64CAF25CAAA3300F2225D /* libssh2.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = D2F64C9525CA99AD00F2225D /* libssh2.xcframework */; }; - D2F64CB125CAAAD100F2225D /* ssh_cmd.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = D2334EC825C1C04700385378 /* ssh_cmd.xcframework */; }; D2FBEC0B27CF505D00FD974A /* browse.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2FBEC0727CF505D00FD974A /* browse.swift */; }; D2FCB4DD2339F9DB00A88108 /* UIScrollView+Paging.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2FCB4DC2339F9DB00A88108 /* UIScrollView+Paging.swift */; }; /* End PBXBuildFile section */ @@ -531,35 +551,70 @@ containerPortal = EA0BA1831C0CC57B00719C1A /* Project object */; proxyType = 1; remoteGlobalIDString = 9827124F262E4BDB00F883FA; + remoteInfo = BlinkFileProviderExtension; + }; + BD67FC83272DF22700C1EE75 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = EA0BA1831C0CC57B00719C1A /* Project object */; + proxyType = 1; + remoteGlobalIDString = BDCB715D268E1577007D7047; + remoteInfo = BlinkConfig; + }; + BD67FC87272E3FF800C1EE75 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = EA0BA1831C0CC57B00719C1A /* Project object */; + proxyType = 1; + remoteGlobalIDString = 07FABB8325C9AEC000E1CC2C; + remoteInfo = SSH; + }; + BD9513EC2B4F5A7A00A7BEBE /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = EA0BA1831C0CC57B00719C1A /* Project object */; + proxyType = 1; + remoteGlobalIDString = BD9513E02B4F5A7A00A7BEBE; remoteInfo = BlinkFileProvider; }; - 98E7D0E6263971C000758CF9 /* PBXContainerItemProxy */ = { + BD9513EE2B4F5A7A00A7BEBE /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = EA0BA1831C0CC57B00719C1A /* Project object */; proxyType = 1; - remoteGlobalIDString = 07FABBAE25C9AECF00E1CC2C; - remoteInfo = BlinkFiles; + remoteGlobalIDString = EA0BA18A1C0CC57B00719C1A; + remoteInfo = Blink; }; - BD67FC83272DF22700C1EE75 /* PBXContainerItemProxy */ = { + BD9514132B4F5F0900A7BEBE /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = EA0BA1831C0CC57B00719C1A /* Project object */; proxyType = 1; remoteGlobalIDString = BDCB715D268E1577007D7047; remoteInfo = BlinkConfig; }; - BD67FC87272E3FF800C1EE75 /* PBXContainerItemProxy */ = { + BD9514172B4F5F0900A7BEBE /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = EA0BA1831C0CC57B00719C1A /* Project object */; + proxyType = 1; + remoteGlobalIDString = 07FABBAE25C9AECF00E1CC2C; + remoteInfo = BlinkFiles; + }; + BD95141B2B4F5F0900A7BEBE /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = EA0BA1831C0CC57B00719C1A /* Project object */; proxyType = 1; remoteGlobalIDString = 07FABB8325C9AEC000E1CC2C; remoteInfo = SSH; }; + BD9514222B4F600800A7BEBE /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = EA0BA1831C0CC57B00719C1A /* Project object */; + proxyType = 1; + remoteGlobalIDString = BD9513E02B4F5A7A00A7BEBE; + remoteInfo = BlinkFileProvider; + }; BD9EA1D12718E19000874007 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = EA0BA1831C0CC57B00719C1A /* Project object */; proxyType = 1; remoteGlobalIDString = BD9EA1C82718E19000874007; - remoteInfo = BlinkFileProviderUI; + remoteInfo = BlinkFileProviderExtensionUI; }; BDBFA30E2728914F00C77798 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; @@ -582,13 +637,6 @@ remoteGlobalIDString = BDCB715D268E1577007D7047; remoteInfo = BlinkConfig; }; - BDCB7186268E160F007D7047 /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = EA0BA1831C0CC57B00719C1A /* Project object */; - proxyType = 1; - remoteGlobalIDString = BDCB715D268E1577007D7047; - remoteInfo = BlinkConfig; - }; BDCB7190268E173D007D7047 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = EA0BA1831C0CC57B00719C1A /* Project object */; @@ -603,12 +651,12 @@ remoteGlobalIDString = BDCB715D268E1577007D7047; remoteInfo = BlinkConfig; }; - BDF471BC268CD17B00A7A41B /* PBXContainerItemProxy */ = { + BDECEA482CDE7544007BBCF6 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = EA0BA1831C0CC57B00719C1A /* Project object */; proxyType = 1; - remoteGlobalIDString = 07FABB8325C9AEC000E1CC2C; - remoteInfo = SSH; + remoteGlobalIDString = BD9513E02B4F5A7A00A7BEBE; + remoteInfo = BlinkFileProvider; }; D218069D25CC27C100B98902 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; @@ -666,13 +714,6 @@ remoteGlobalIDString = BDBFA3042728914F00C77798; remoteInfo = BlinkCode; }; - D2E4F92D20B2BB4500B30F7B /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = D2E4F92920B2BB4500B30F7B /* curl_ios_static.xcodeproj */; - proxyType = 2; - remoteGlobalIDString = 2219DFCF1FD73AF500675252; - remoteInfo = curl_ios_static; - }; /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -684,15 +725,19 @@ files = ( D2A9B2F8272E6F26009FCBDE /* BlinkCode.framework in Embed Frameworks */, D2C7710826EA49AA001B5659 /* openssl.xcframework in Embed Frameworks */, + BDF2B8D32BC480CC00B9C7EA /* vim.xcframework in Embed Frameworks */, + BDF2B8D52BC480D100B9C7EA /* xxd.xcframework in Embed Frameworks */, D2334F0125C1C28F00385378 /* shell.xcframework in Embed Frameworks */, 07FABB9A25C9AEC100E1CC2C /* SSH.framework in Embed Frameworks */, BDCB7166268E1577007D7047 /* BlinkConfig.framework in Embed Frameworks */, D24AFD59222410E700CFD3C1 /* MBProgressHUD.framework in Embed Frameworks */, D2334F0725C1C28F00385378 /* text.xcframework in Embed Frameworks */, + BD801D212C9352E800556515 /* curl_ios.xcframework in Embed Frameworks */, + BDECEA472CDE7544007BBCF6 /* BlinkFileProvider.framework in Embed Frameworks */, + BDE84C522BB233FB00457391 /* bc_ios.xcframework in Embed Frameworks */, D2334EF525C1C28F00385378 /* ios_system.xcframework in Embed Frameworks */, D2334F0525C1C28F00385378 /* tar.xcframework in Embed Frameworks */, D22277E22A26115300D4C708 /* BlinkSnippets.framework in Embed Frameworks */, - D2334F0325C1C28F00385378 /* ssh_cmd.xcframework in Embed Frameworks */, D2334EEF25C1C28F00385378 /* awk.xcframework in Embed Frameworks */, D2A0C2172600D16300F0DF97 /* Protobuf_C_.xcframework in Embed Frameworks */, D2334EF325C1C28F00385378 /* files.xcframework in Embed Frameworks */, @@ -709,8 +754,8 @@ dstSubfolderSpec = 13; files = ( D218069C25CC27C100B98902 /* AppKitBridge.bundle in Embed PlugIns */, - 9827126B262E4BDB00F883FA /* BlinkFileProvider.appex in Embed PlugIns */, - BD9EA1D32718E19000874007 /* BlinkFileProviderUI.appex in Embed PlugIns */, + 9827126B262E4BDB00F883FA /* BlinkFileProviderExtension.appex in Embed PlugIns */, + BD9EA1D32718E19000874007 /* BlinkFileProviderExtensionUI.appex in Embed PlugIns */, ); name = "Embed PlugIns"; runOnlyForDeploymentPostprocessing = 0; @@ -798,12 +843,9 @@ 803B99E2258381B200DC99C8 /* SettingsHostingController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsHostingController.swift; sourceTree = ""; }; 85A34303200A837A009324F1 /* webfontloader.js */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.javascript; path = webfontloader.js; sourceTree = ""; }; 85A34308200A8FAF009324F1 /* term.css */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.css; path = term.css; sourceTree = ""; }; - 98271250262E4BDB00F883FA /* BlinkFileProvider.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = BlinkFileProvider.appex; sourceTree = BUILT_PRODUCTS_DIR; }; - 98271252262E4BDB00F883FA /* FileProviderExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileProviderExtension.swift; sourceTree = ""; }; - 98271256262E4BDB00F883FA /* FileProviderEnumerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileProviderEnumerator.swift; sourceTree = ""; }; + 98271250262E4BDB00F883FA /* BlinkFileProviderExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = BlinkFileProviderExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 98271258262E4BDB00F883FA /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; - 98271259262E4BDB00F883FA /* BlinkFileProvider.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = BlinkFileProvider.entitlements; sourceTree = ""; }; - 98E7D0BD2638B46400758CF9 /* BlinkItemReference.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlinkItemReference.swift; sourceTree = ""; }; + 98271259262E4BDB00F883FA /* BlinkFileProviderExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = BlinkFileProviderExtension.entitlements; sourceTree = ""; }; B700AED71DD0F2C200100EBF /* CloudKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CloudKit.framework; path = System/Library/Frameworks/CloudKit.framework; sourceTree = SDKROOT; }; B75112CB1DE4A7B10040C693 /* BKiCloudConfigurationViewController.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = BKiCloudConfigurationViewController.h; sourceTree = ""; }; B75112CC1DE4A7B10040C693 /* BKiCloudConfigurationViewController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = BKiCloudConfigurationViewController.m; sourceTree = ""; }; @@ -823,9 +865,16 @@ B7D6A6281E2D43A800EDF7B0 /* BKSmartKeysConfigViewController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = BKSmartKeysConfigViewController.m; sourceTree = ""; }; BD028AF22A8EC509002F5F54 /* TrialSupportView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrialSupportView.swift; sourceTree = ""; }; BD1758AB26EA8C5400AEC545 /* MenuController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuController.swift; sourceTree = ""; }; - BD2E27B429BAA8DA003AF1DA /* ReplaySubject.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReplaySubject.swift; sourceTree = ""; }; + BD19DB402B056E9C003A4367 /* SSHCommandTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SSHCommandTest.swift; sourceTree = ""; }; + BD23B6DF2CB058550041C38D /* ios_patches.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = ios_patches.h; sourceTree = ""; }; + BD23B6E02CB0585B0041C38D /* ios_patches.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = ios_patches.m; sourceTree = ""; }; + BD2C754D2D75FA7F007BFE77 /* Walkthrough.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Walkthrough.swift; sourceTree = ""; }; + BD33F7802AAA426D00CD16EE /* MoshBootstrap.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MoshBootstrap.swift; sourceTree = ""; }; + BD33F7862AAA7C4300CD16EE /* MoshBootstrapTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoshBootstrapTests.swift; sourceTree = ""; }; BD3E1E4F278D190500333C44 /* Archive.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Archive.swift; sourceTree = ""; }; - BD44DCE526D6BEAC00054338 /* BlinkItemIdentifier.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BlinkItemIdentifier.swift; sourceTree = ""; }; + BD4DBAFB2CADB3AF00538194 /* WorkingSetDatabase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkingSetDatabase.swift; sourceTree = ""; }; + BD4F79F72CB4975E00FE67EA /* WorkingSetDatabaseTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkingSetDatabaseTests.swift; sourceTree = ""; }; + BD4F7A062CBC9FD300FE67EA /* FileTranslatorConnectionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileTranslatorConnectionTests.swift; sourceTree = ""; }; BD67FC78272B30F300C1EE75 /* Messages.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Messages.swift; sourceTree = ""; }; BD67FC9A2732D4D300C1EE75 /* BackgroundTaskMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundTaskMonitor.swift; sourceTree = ""; }; BD74A7A6290061DE00ED01CF /* WhatsNewInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WhatsNewInfo.swift; sourceTree = ""; }; @@ -834,8 +883,12 @@ BD792A442A3B6A78009EE35F /* GitHubSnippets.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GitHubSnippets.swift; sourceTree = ""; }; BD81521C27387D1F002BB169 /* Certificates.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Certificates.swift; sourceTree = ""; }; BD8152532743FF84002BB169 /* skstore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = skstore.swift; sourceTree = ""; }; + BD818A042AAFC18400956488 /* mosh.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = mosh.swift; sourceTree = ""; }; + BD818A0B2AB120B800956488 /* MoshServerParams.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoshServerParams.swift; sourceTree = ""; }; + BD818A122AB3865F00956488 /* MoshCommand.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MoshCommand.swift; sourceTree = ""; }; + BD818A142AB3A40100956488 /* MoshClientParams.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MoshClientParams.swift; sourceTree = ""; }; BD835DD027A0BD19002C37D7 /* ReplaySubject.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReplaySubject.swift; sourceTree = ""; }; - BD896F7A26CEAD37004313E6 /* FileTranslatorCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileTranslatorCache.swift; sourceTree = ""; }; + BD88697A2CF4DD9A00F71119 /* 1810Migration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = 1810Migration.swift; sourceTree = ""; }; BD8BBF0825F819970084705F /* SEKeyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SEKeyTests.swift; sourceTree = ""; }; BD8BBFAF25F947710084705F /* Keys.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Keys.swift; sourceTree = ""; }; BD8BBFF426001B020084705F /* AgentConstraints.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AgentConstraints.swift; sourceTree = ""; }; @@ -847,17 +900,26 @@ BD8D896F25DC534F00E55D9E /* user_key */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = user_key; sourceTree = ""; }; BD8D897025DC534F00E55D9E /* id_ecdsa.pub */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = id_ecdsa.pub; sourceTree = ""; }; BD8D897125DC534F00E55D9E /* user_key-cert.pub */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = "user_key-cert.pub"; sourceTree = ""; }; - BD8DB629279B1EC800497C88 /* SSHClient.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SSHClient.swift; sourceTree = ""; }; - BD8DB641279B2FA200497C88 /* SSHClient.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SSHClient.swift; sourceTree = ""; }; BD8DB645279B512900497C88 /* CodeFileSystem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CodeFileSystem.swift; sourceTree = ""; }; BD8DB646279B512900497C88 /* CodeFileSystemService.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CodeFileSystemService.swift; sourceTree = ""; }; BD90BE492A18466E00DA5686 /* AgentForwardPromptPickerView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AgentForwardPromptPickerView.swift; sourceTree = ""; }; + BD9513E12B4F5A7A00A7BEBE /* BlinkFileProvider.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = BlinkFileProvider.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + BD9513E32B4F5A7A00A7BEBE /* BlinkFileProvider.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = BlinkFileProvider.h; sourceTree = ""; }; + BD9513EA2B4F5A7A00A7BEBE /* BlinkFileProviderTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = BlinkFileProviderTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + BD9513F12B4F5A7A00A7BEBE /* BlinkFileProviderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlinkFileProviderTests.swift; sourceTree = ""; }; + BD9513FF2B4F5B5600A7BEBE /* ReplaySubject.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReplaySubject.swift; sourceTree = ""; }; + BD9514022B4F5B9400A7BEBE /* SSHClient.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SSHClient.swift; sourceTree = ""; }; + BD9514042B4F5B9400A7BEBE /* NSFileProviderError.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NSFileProviderError.swift; sourceTree = ""; }; + BD95141E2B4F5F4000A7BEBE /* main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = main.swift; sourceTree = ""; }; + BD9514262B4F606500A7BEBE /* SSHClient.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SSHClient.swift; sourceTree = ""; }; + BD9514302B4F660B00A7BEBE /* FileProviderReplicatedExtension.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FileProviderReplicatedExtension.swift; sourceTree = ""; }; + BD9514322B4F661600A7BEBE /* FileProviderItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FileProviderItem.swift; sourceTree = ""; }; + BD9514342B4F662100A7BEBE /* FileProviderReplicatedEnumerator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FileProviderReplicatedEnumerator.swift; sourceTree = ""; }; BD98AC83260BD8DC00B4E6A1 /* SSHAgentAdd.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SSHAgentAdd.swift; sourceTree = ""; }; - BD98AC94260BE20000B4E6A1 /* SSHAgentPool.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SSHAgentPool.swift; sourceTree = ""; }; + BD98AC94260BE20000B4E6A1 /* SSHDefaultAgent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SSHDefaultAgent.swift; sourceTree = ""; }; BD9BF7E3262A6B0300B02074 /* SOCKS.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SOCKS.swift; sourceTree = ""; }; BD9BF7E8262A6B0F00B02074 /* SOCKSTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SOCKSTests.swift; sourceTree = ""; }; - BD9EA17C2718D6C400874007 /* NSFileProviderError.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NSFileProviderError.swift; sourceTree = ""; }; - BD9EA1C92718E19000874007 /* BlinkFileProviderUI.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = BlinkFileProviderUI.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + BD9EA1C92718E19000874007 /* BlinkFileProviderExtensionUI.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = BlinkFileProviderExtensionUI.appex; sourceTree = BUILT_PRODUCTS_DIR; }; BD9EA1CB2718E19000874007 /* DocumentActionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DocumentActionViewController.swift; sourceTree = ""; }; BD9EA1CE2718E19000874007 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/MainInterface.storyboard; sourceTree = ""; }; BD9EA1D02718E19000874007 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; @@ -868,8 +930,11 @@ BD9EA20C271F664D00874007 /* Publisher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Publisher.swift; sourceTree = ""; }; BD9EA215271F83B400874007 /* BlinkLoggingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlinkLoggingTests.swift; sourceTree = ""; }; BDACC7742A6F100D00D0B261 /* TrialNotification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrialNotification.swift; sourceTree = ""; }; + BDB129F22D077EB3006970A1 /* BookmarkedLocationsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkedLocationsManager.swift; sourceTree = ""; }; + BDB129F82D08B4DE006970A1 /* BookmarkedLocationsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkedLocationsView.swift; sourceTree = ""; }; BDB72CB127A9C08500DCC446 /* StoreKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = StoreKit.framework; path = System/Library/Frameworks/StoreKit.framework; sourceTree = SDKROOT; }; BDB8BEA726E008190093BF48 /* OwnAlertController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OwnAlertController.swift; sourceTree = ""; }; + BDBA46CC2CD2EDD300AB44A0 /* FileProviderReplicatedExtension+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FileProviderReplicatedExtension+Helpers.swift"; sourceTree = ""; }; BDBFA3052728914F00C77798 /* BlinkCode.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = BlinkCode.framework; sourceTree = BUILT_PRODUCTS_DIR; }; BDBFA3072728914F00C77798 /* BlinkCode.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = BlinkCode.h; sourceTree = ""; }; BDBFA30C2728914F00C77798 /* BlinkCodeTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = BlinkCodeTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -899,6 +964,15 @@ BDD6D148275951D900E76F1F /* BKGlobalSSHConfig.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BKGlobalSSHConfig.swift; sourceTree = ""; }; BDE7125C2A141E3100164F70 /* SSHAgentUserPrompt.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SSHAgentUserPrompt.swift; sourceTree = ""; }; BDE7C45B29DCAEFA005E033E /* FileLocationPathTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileLocationPathTests.swift; sourceTree = ""; }; + BDE84C3B2BAE335100457391 /* vim */ = {isa = PBXFileReference; lastKnownFileType = folder; path = vim; sourceTree = ""; }; + BDE84C502BB233F100457391 /* bc_ios.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = bc_ios.xcframework; path = xcfs/.build/artifacts/xcfs/bc/bc_ios.xcframework; sourceTree = SOURCE_ROOT; }; + BDE84C762BB33AB800457391 /* xxd.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = xxd.xcframework; path = xcfs/.build/artifacts/xcfs/xxd/xxd.xcframework; sourceTree = SOURCE_ROOT; }; + BDE84C772BB33AD700457391 /* vim.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = vim.xcframework; path = xcfs/.build/artifacts/xcfs/vim/vim.xcframework; sourceTree = SOURCE_ROOT; }; + BDEEE36B2B8951D3003003FD /* get_frameworks.sh */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.script.sh; path = get_frameworks.sh; sourceTree = ""; }; + BDF041132CA363F0005B7138 /* FilesTranslatorConnection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilesTranslatorConnection.swift; sourceTree = ""; }; + BDF2B8D62BC481F000B9C7EA /* curl_ios.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = curl_ios.xcframework; path = xcfs/.build/artifacts/xcfs/curl_ios/curl_ios.xcframework; sourceTree = SOURCE_ROOT; }; + BDF40FE92C14A6CE00DF41C1 /* AgentSettingsView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AgentSettingsView.swift; sourceTree = ""; }; + BDFA97342D959BC90020BBF2 /* TypingText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TypingText.swift; sourceTree = ""; }; C94437551D8311960096F84E /* BKResource.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = BKResource.h; sourceTree = ""; }; C94437561D8311960096F84E /* BKResource.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = BKResource.m; sourceTree = ""; }; C944375F1D831CD30096F84E /* Themes */ = {isa = PBXFileReference; lastKnownFileType = folder; path = Themes; sourceTree = ""; }; @@ -978,8 +1052,6 @@ D22278122A2631E700D4C708 /* SnippetsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SnippetsViewController.swift; sourceTree = ""; }; D22378FC27A7B8DA002D5C6D /* XCConfig.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = XCConfig.h; sourceTree = ""; }; D22378FD27A7B8DA002D5C6D /* XCConfig.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = XCConfig.m; sourceTree = ""; }; - D22A353626C1474200943C71 /* BKMiniLog.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = BKMiniLog.h; sourceTree = ""; }; - D22A353726C1474200943C71 /* BKMiniLog.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = BKMiniLog.m; sourceTree = ""; }; D22B16D728CF6ED20004EEC1 /* NewPasskeyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewPasskeyView.swift; sourceTree = ""; }; D231F5062A54478800ED82B0 /* BlinkMenu.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = BlinkMenu.m; sourceTree = ""; }; D231F5082A54479400ED82B0 /* BlinkMenu.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = BlinkMenu.h; sourceTree = ""; }; @@ -990,7 +1062,6 @@ D2334EC525C1C04700385378 /* mosh.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = mosh.xcframework; path = xcfs/.build/artifacts/xcfs/mosh/mosh.xcframework; sourceTree = SOURCE_ROOT; }; D2334EC625C1C04700385378 /* network_ios.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = network_ios.xcframework; path = xcfs/.build/artifacts/xcfs/network_ios/network_ios.xcframework; sourceTree = SOURCE_ROOT; }; D2334EC725C1C04700385378 /* text.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = text.xcframework; path = xcfs/.build/artifacts/xcfs/text/text.xcframework; sourceTree = SOURCE_ROOT; }; - D2334EC825C1C04700385378 /* ssh_cmd.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = ssh_cmd.xcframework; path = xcfs/.build/artifacts/xcfs/ssh_cmd/ssh_cmd.xcframework; sourceTree = SOURCE_ROOT; }; D2334EC925C1C04700385378 /* shell.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = shell.xcframework; path = xcfs/.build/artifacts/xcfs/shell/shell.xcframework; sourceTree = SOURCE_ROOT; }; D2334ECA25C1C04700385378 /* files.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = files.xcframework; path = xcfs/.build/artifacts/xcfs/files/files.xcframework; sourceTree = SOURCE_ROOT; }; D2334ECB25C1C04700385378 /* Protobuf_C_.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = Protobuf_C_.xcframework; path = xcfs/.build/artifacts/xcfs/Protobuf_C_/Protobuf_C_.xcframework; sourceTree = SOURCE_ROOT; }; @@ -1008,11 +1079,9 @@ D23EA944260379EB00BCF1FF /* KeyListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyListView.swift; sourceTree = ""; }; D23EA9582604CB4B00BCF1FF /* FixedTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FixedTextField.swift; sourceTree = ""; }; D23FFC69261C2D46003E9227 /* template_setup.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = template_setup.xcconfig; sourceTree = ""; }; - D240806020BC8DF800F30099 /* tool_main.c */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.c; name = tool_main.c; path = Frameworks/ios_system/curl/curl/src/tool_main.c; sourceTree = SOURCE_ROOT; }; D241CBBE23040732003D64A5 /* KBKeyViewFlexible.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = KBKeyViewFlexible.swift; sourceTree = ""; }; D241CBBF23040732003D64A5 /* KBKeyViewArrows.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = KBKeyViewArrows.swift; sourceTree = ""; }; D241CBC023040733003D64A5 /* KBDevice.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = KBDevice.swift; sourceTree = ""; }; - D241CBC123040733003D64A5 /* KBSound.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = KBSound.swift; sourceTree = ""; }; D241CBC223040733003D64A5 /* KBView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = KBView.swift; sourceTree = ""; }; D241CBC323040733003D64A5 /* KBKey.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = KBKey.swift; sourceTree = ""; }; D241CBC423040733003D64A5 /* KBSection.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = KBSection.swift; sourceTree = ""; }; @@ -1041,12 +1110,10 @@ D25DE9BE2939EB36008246EB /* NonStdIO+ArgumentParser.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "NonStdIO+ArgumentParser.swift"; sourceTree = ""; }; D25DE9BF2939EB36008246EB /* NonStdIO+Spinner.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "NonStdIO+Spinner.swift"; sourceTree = ""; }; D263A7672AA9AFE7001C6CFC /* device_info.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = device_info.m; sourceTree = ""; }; - D264D2AC28F81DD4002B1B14 /* EarlyFeaturesAccessLetterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EarlyFeaturesAccessLetterView.swift; sourceTree = ""; }; D264D2AF28F84592002B1B14 /* GridView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GridView.swift; sourceTree = ""; }; D264D2B028F84592002B1B14 /* UnavailErrorView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UnavailErrorView.swift; sourceTree = ""; }; D264D2B128F84592002B1B14 /* Models.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Models.swift; sourceTree = ""; }; D264D2B528F84724002B1B14 /* whatsnew.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = whatsnew.m; sourceTree = ""; }; - D264D2B728F96C66002B1B14 /* WhatsNewSceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WhatsNewSceneDelegate.swift; sourceTree = ""; }; D265FBBA2317DD3C0017EAC4 /* BlinkTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = BlinkTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; D265FBBE2317DD3C0017EAC4 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; D265FBC42317E5090017EAC4 /* SessionParamsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionParamsTests.swift; sourceTree = ""; }; @@ -1096,10 +1163,7 @@ D2AD8E6527A2BAFA00DED28D /* EntitlementsManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EntitlementsManager.swift; sourceTree = ""; }; D2AD8E6627A2BAFA00DED28D /* PurchasesUserModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PurchasesUserModel.swift; sourceTree = ""; }; D2AD8E6727A2BAFA00DED28D /* Purchases.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Purchases.swift; sourceTree = ""; }; - D2AD8E6E27A2BAFA00DED28D /* PurchasePageView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PurchasePageView.swift; sourceTree = ""; }; - D2AD8E6F27A2BAFA00DED28D /* CheckmarkRow.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CheckmarkRow.swift; sourceTree = ""; }; D2AD8E8327A2C80C00DED28D /* SettingsView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; - D2AD8E8727A2C81900DED28D /* ExplanationView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ExplanationView.swift; sourceTree = ""; }; D2AD9ADD22DB80DE00861F66 /* SessionRegistry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionRegistry.swift; sourceTree = ""; }; D2AE682728D05FD0003E4338 /* WebAuthnKey.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebAuthnKey.swift; sourceTree = ""; }; D2B0BD1B2720312C00485854 /* GesturesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GesturesView.swift; sourceTree = ""; }; @@ -1154,14 +1218,12 @@ D2DE0DCD260323F400A69B6F /* id_ed25519-passphrase */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = "id_ed25519-passphrase"; sourceTree = ""; }; D2DE0DCE260323F400A69B6F /* id_ed25519-passphrase.pub */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = "id_ed25519-passphrase.pub"; sourceTree = ""; }; D2DE0DDD260331F300A69B6F /* NewKeyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewKeyView.swift; sourceTree = ""; }; - D2E4F92920B2BB4500B30F7B /* curl_ios_static.xcodeproj */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.pb-project"; name = curl_ios_static.xcodeproj; path = ios_system/curl_ios_static.xcodeproj; sourceTree = ""; }; D2E5454427C4C83C002635A2 /* FeedbackView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedbackView.swift; sourceTree = ""; }; D2E5454927C4D422002635A2 /* SupportView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SupportView.swift; sourceTree = ""; }; D2E66D132695B100007D42AA /* HostListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HostListView.swift; sourceTree = ""; }; D2EC7B4B25DBC922008B6B3C /* XCTestCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XCTestCase.swift; sourceTree = ""; }; D2ECBF4829645814004E95C4 /* BuildApi.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BuildApi.swift; sourceTree = ""; }; D2ED4A6E239BB12E000DC67F /* KeyCaptureView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyCaptureView.swift; sourceTree = ""; }; - D2EFE1EE20B7FAFC0087888B /* link_files.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = link_files.m; sourceTree = ""; }; D2F30EAC205009CD008C5F35 /* base64js.min.js */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.javascript; path = base64js.min.js; sourceTree = ""; }; D2F330C920A6CB840074ADD7 /* help.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = help.m; sourceTree = ""; }; D2F330CB20A6D98C0074ADD7 /* config.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = config.m; sourceTree = ""; }; @@ -1219,9 +1281,26 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 98E7D0E4263971C000758CF9 /* BlinkFiles.framework in Frameworks */, - BDF471BA268CD17B00A7A41B /* SSH.framework in Frameworks */, - BDCB7184268E160F007D7047 /* BlinkConfig.framework in Frameworks */, + BD9514202B4F600800A7BEBE /* BlinkFileProvider.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + BD9513DE2B4F5A7A00A7BEBE /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + BD4DBAFA2CADA82700538194 /* SQLite in Frameworks */, + BD9514112B4F5F0800A7BEBE /* BlinkConfig.framework in Frameworks */, + BD9514192B4F5F0900A7BEBE /* SSH.framework in Frameworks */, + BD9514152B4F5F0900A7BEBE /* BlinkFiles.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + BD9513E72B4F5A7A00A7BEBE /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + BD9513EB2B4F5A7A00A7BEBE /* BlinkFileProvider.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1307,33 +1386,34 @@ files = ( D222780A2A26216900D4C708 /* TreeSitterBashQueries in Frameworks */, D28DB3AC210B110C008A1139 /* libcompression.tbd in Frameworks */, - D2334F0225C1C28F00385378 /* ssh_cmd.xcframework in Frameworks */, + BDF2B8DF2BC48D2D00B9C7EA /* libssh2.xcframework in Frameworks */, D2334F0625C1C28F00385378 /* text.xcframework in Frameworks */, + BDFA97322D7F49EF0020BBF2 /* ConfettiSwiftUI in Frameworks */, 07FABB9925C9AEC100E1CC2C /* SSH.framework in Frameworks */, D222780F2A26217A00D4C708 /* Runestone in Frameworks */, D2334EEE25C1C28F00385378 /* awk.xcframework in Frameworks */, BDCB7165268E1577007D7047 /* BlinkConfig.framework in Frameworks */, D2334EF825C1C28F00385378 /* mosh.xcframework in Frameworks */, - D218076325CD4FDC00B98902 /* LibSSH.xcframework in Frameworks */, D2334F0025C1C28F00385378 /* shell.xcframework in Frameworks */, D2A0C2162600D16300F0DF97 /* Protobuf_C_.xcframework in Frameworks */, D2334F0C25C1C3C600385378 /* OpenSSH.xcframework in Frameworks */, D2C7710726EA49AA001B5659 /* openssl.xcframework in Frameworks */, - D2F64CAF25CAAA3300F2225D /* libssh2.xcframework in Frameworks */, D222780C2A26216900D4C708 /* TreeSitterBashRunestone in Frameworks */, D2334EF425C1C28F00385378 /* ios_system.xcframework in Frameworks */, 07FABBC425C9AECF00E1CC2C /* BlinkFiles.framework in Frameworks */, BD7AFC4128E8E98C004BCDA4 /* Base32Kit in Frameworks */, 0732F1291D06324200AB5438 /* libcurses.tbd in Frameworks */, - D2F64CA925CAA92700F2225D /* curl_ios_static.framework in Frameworks */, 0732F1271D06323A00AB5438 /* libz.tbd in Frameworks */, + BDE84C512BB233FB00457391 /* bc_ios.xcframework in Frameworks */, D297F00F29012FDB002A24F9 /* CachedAsyncImage in Frameworks */, D22278082A26216900D4C708 /* TreeSitterBash in Frameworks */, - D2F64CB125CAAAD100F2225D /* ssh_cmd.xcframework in Frameworks */, 07FAB90F25C8E94E00E1CC2C /* ArgumentParser in Frameworks */, + BDF2B8DD2BC48D2800B9C7EA /* LibSSH.xcframework in Frameworks */, D2334EFA25C1C28F00385378 /* network_ios.xcframework in Frameworks */, D2A6398928CFA0B90066FD18 /* SwiftCBOR in Frameworks */, + BDECEA462CDE7544007BBCF6 /* BlinkFileProvider.framework in Frameworks */, D24AFD58222410E700CFD3C1 /* MBProgressHUD.framework in Frameworks */, + BD801D202C9352E800556515 /* curl_ios.xcframework in Frameworks */, D22277E12A26115300D4C708 /* BlinkSnippets.framework in Frameworks */, B700AED81DD0F2C200100EBF /* CloudKit.framework in Frameworks */, D2334F0425C1C28F00385378 /* tar.xcframework in Frameworks */, @@ -1402,7 +1482,6 @@ BD1758AB26EA8C5400AEC545 /* MenuController.swift */, D2A80978270713D200CD0FAF /* FeatureFlags.swift */, D2ECBF4829645814004E95C4 /* BuildApi.swift */, - D2CC13B629C05EE7008C71FA /* Intro.swift */, D231F5082A54479400ED82B0 /* BlinkMenu.h */, D231F5062A54478800ED82B0 /* BlinkMenu.m */, ); @@ -1426,6 +1505,7 @@ 85A34308200A8FAF009324F1 /* term.css */, D2496F3B20038B3300E75FE9 /* hterm_all.min.js */, D29392B62004D785001FB2AA /* hterm_all.patches.js */, + BDE84C3B2BAE335100457391 /* vim */, C94437641D838ABF0096F84E /* Fonts */, C944375F1D831CD30096F84E /* Themes */, 0732F04A1D062B9A00AB5438 /* locales.bundle */, @@ -1436,6 +1516,10 @@ 0732F04F1D062BB300AB5438 /* Frameworks */ = { isa = PBXGroup; children = ( + BDF2B8D62BC481F000B9C7EA /* curl_ios.xcframework */, + BDE84C772BB33AD700457391 /* vim.xcframework */, + BDE84C762BB33AB800457391 /* xxd.xcframework */, + BDE84C502BB233F100457391 /* bc_ios.xcframework */, D2771510287F0EA200D31F4E /* libbuild_cli.a */, BDB72CB127A9C08500DCC446 /* StoreKit.framework */, D248EC542689EAD7009FD817 /* SSHConfig */, @@ -1450,11 +1534,9 @@ D2334EC925C1C04700385378 /* shell.xcframework */, D2F64C9525CA99AD00F2225D /* libssh2.xcframework */, D2F64C9425CA99AD00F2225D /* openssl.xcframework */, - D2334EC825C1C04700385378 /* ssh_cmd.xcframework */, D2334ECD25C1C04700385378 /* tar.xcframework */, D2334EC725C1C04700385378 /* text.xcframework */, D28DB3AB210B110B008A1139 /* libcompression.tbd */, - D2E4F92920B2BB4500B30F7B /* curl_ios_static.xcodeproj */, B700AED71DD0F2C200100EBF /* CloudKit.framework */, 0732F1281D06324200AB5438 /* libcurses.tbd */, 0732F1261D06323A00AB5438 /* libz.tbd */, @@ -1527,7 +1609,7 @@ 07FAB8EE25C8E6C500E1CC2C /* SSHConfig.swift */, 07FAB8EF25C8E6C500E1CC2C /* SSHConfigProvider.swift */, BD98AC83260BD8DC00B4E6A1 /* SSHAgentAdd.swift */, - BD98AC94260BE20000B4E6A1 /* SSHAgentPool.swift */, + BD98AC94260BE20000B4E6A1 /* SSHDefaultAgent.swift */, BDE7125C2A141E3100164F70 /* SSHAgentUserPrompt.swift */, ); path = ssh; @@ -1615,29 +1697,14 @@ path = Notifications; sourceTree = ""; }; - 98271251262E4BDB00F883FA /* BlinkFileProvider */ = { + 98271251262E4BDB00F883FA /* BlinkFileProviderExtension */ = { isa = PBXGroup; children = ( - BD2E27B329BAA8DA003AF1DA /* Publisher */, - 98E7D0AF2638B3AE00758CF9 /* Models */, - BD8DB628279B1EC800497C88 /* SSH */, - BD9EA17C2718D6C400874007 /* NSFileProviderError.swift */, - 98271252262E4BDB00F883FA /* FileProviderExtension.swift */, - 98271256262E4BDB00F883FA /* FileProviderEnumerator.swift */, 98271258262E4BDB00F883FA /* Info.plist */, - 98271259262E4BDB00F883FA /* BlinkFileProvider.entitlements */, + 98271259262E4BDB00F883FA /* BlinkFileProviderExtension.entitlements */, + BD95141E2B4F5F4000A7BEBE /* main.swift */, ); - path = BlinkFileProvider; - sourceTree = ""; - }; - 98E7D0AF2638B3AE00758CF9 /* Models */ = { - isa = PBXGroup; - children = ( - BD44DCE526D6BEAC00054338 /* BlinkItemIdentifier.swift */, - 98E7D0BD2638B46400758CF9 /* BlinkItemReference.swift */, - BD896F7A26CEAD37004313E6 /* FileTranslatorCache.swift */, - ); - path = Models; + path = BlinkFileProviderExtension; sourceTree = ""; }; B75112C71DE4A7680040C693 /* UserConfiguration */ = { @@ -1676,14 +1743,17 @@ path = Security; sourceTree = ""; }; - BD2E27B329BAA8DA003AF1DA /* Publisher */ = { + BD33F77F2AAA426D00CD16EE /* mosh */ = { isa = PBXGroup; children = ( - BD2E27B429BAA8DA003AF1DA /* ReplaySubject.swift */, + BD818A042AAFC18400956488 /* mosh.swift */, + BD33F7802AAA426D00CD16EE /* MoshBootstrap.swift */, + BD818A142AB3A40100956488 /* MoshClientParams.swift */, + BD818A122AB3865F00956488 /* MoshCommand.swift */, + BD818A0B2AB120B800956488 /* MoshServerParams.swift */, ); - name = Publisher; - path = BlinkCode/Publisher; - sourceTree = SOURCE_ROOT; + path = mosh; + sourceTree = ""; }; BD835DCF27A0BD19002C37D7 /* Publisher */ = { isa = PBXGroup; @@ -1706,24 +1776,60 @@ name = keys; sourceTree = ""; }; - BD8DB628279B1EC800497C88 /* SSH */ = { + BD9513E22B4F5A7A00A7BEBE /* BlinkFileProvider */ = { isa = PBXGroup; children = ( - BD8DB629279B1EC800497C88 /* SSHClient.swift */, + BD9514012B4F5B9400A7BEBE /* SSH */, + BD9513FE2B4F5B5600A7BEBE /* Publisher */, + BD9514342B4F662100A7BEBE /* FileProviderReplicatedEnumerator.swift */, + BD9514322B4F661600A7BEBE /* FileProviderItem.swift */, + BD9514302B4F660B00A7BEBE /* FileProviderReplicatedExtension.swift */, + BDBA46CC2CD2EDD300AB44A0 /* FileProviderReplicatedExtension+Helpers.swift */, + BD9514042B4F5B9400A7BEBE /* NSFileProviderError.swift */, + BDF041132CA363F0005B7138 /* FilesTranslatorConnection.swift */, + BD9513E32B4F5A7A00A7BEBE /* BlinkFileProvider.h */, + BD4DBAFB2CADB3AF00538194 /* WorkingSetDatabase.swift */, + ); + path = BlinkFileProvider; + sourceTree = ""; + }; + BD9513F02B4F5A7A00A7BEBE /* BlinkFileProviderTests */ = { + isa = PBXGroup; + children = ( + BD9513F12B4F5A7A00A7BEBE /* BlinkFileProviderTests.swift */, + BD4F7A062CBC9FD300FE67EA /* FileTranslatorConnectionTests.swift */, + BD4F79F72CB4975E00FE67EA /* WorkingSetDatabaseTests.swift */, + ); + path = BlinkFileProviderTests; + sourceTree = ""; + }; + BD9513FE2B4F5B5600A7BEBE /* Publisher */ = { + isa = PBXGroup; + children = ( + BD9513FF2B4F5B5600A7BEBE /* ReplaySubject.swift */, + ); + name = Publisher; + path = BlinkCode/Publisher; + sourceTree = SOURCE_ROOT; + }; + BD9514012B4F5B9400A7BEBE /* SSH */ = { + isa = PBXGroup; + children = ( + BD9514022B4F5B9400A7BEBE /* SSHClient.swift */, ); path = SSH; sourceTree = ""; }; - BD8DB640279B2FA200497C88 /* SSH */ = { + BD9514252B4F606500A7BEBE /* SSH */ = { isa = PBXGroup; children = ( - BD8DB641279B2FA200497C88 /* SSHClient.swift */, + BD9514262B4F606500A7BEBE /* SSHClient.swift */, ); name = SSH; path = BlinkFileProvider/SSH; sourceTree = SOURCE_ROOT; }; - BD9EA1CA2718E19000874007 /* BlinkFileProviderUI */ = { + BD9EA1CA2718E19000874007 /* BlinkFileProviderExtensionUI */ = { isa = PBXGroup; children = ( BD9EA1CB2718E19000874007 /* DocumentActionViewController.swift */, @@ -1731,7 +1837,7 @@ BD9EA1D02718E19000874007 /* Info.plist */, BD9EA202271A3EFE00874007 /* Media.xcassets */, ); - path = BlinkFileProviderUI; + path = BlinkFileProviderExtensionUI; sourceTree = ""; }; BD9EA1ED2719D28800874007 /* Migrator */ = { @@ -1739,6 +1845,7 @@ children = ( BD9EA1FD271A148700874007 /* 1400Migration.swift */, D2B1DEB42A669342001C6D3B /* 1620Migration.swift */, + BD88697A2CF4DD9A00F71119 /* 1810Migration.swift */, BD9EA1F9271A148700874007 /* Migrator.swift */, ); path = Migrator; @@ -1753,11 +1860,19 @@ path = BlinkLogging; sourceTree = ""; }; + BDB129F92D08B4DE006970A1 /* BookmarkedLocations */ = { + isa = PBXGroup; + children = ( + BDB129F82D08B4DE006970A1 /* BookmarkedLocationsView.swift */, + ); + path = BookmarkedLocations; + sourceTree = ""; + }; BDBFA3062728914F00C77798 /* BlinkCode */ = { isa = PBXGroup; children = ( + BD9514252B4F606500A7BEBE /* SSH */, BD835DCF27A0BD19002C37D7 /* Publisher */, - BD8DB640279B2FA200497C88 /* SSH */, BD81521C27387D1F002BB169 /* Certificates.swift */, BD8DB645279B512900497C88 /* CodeFileSystem.swift */, BD8DB646279B512900497C88 /* CodeFileSystemService.swift */, @@ -1797,8 +1912,6 @@ BDCB7175268E15A2007D7047 /* SEKey.swift */, BDCB716D268E15A1007D7047 /* UICKeyChainStore.h */, BDCB716E268E15A1007D7047 /* UICKeyChainStore.m */, - D22A353626C1474200943C71 /* BKMiniLog.h */, - D22A353726C1474200943C71 /* BKMiniLog.m */, BDCB7161268E1577007D7047 /* Info.plist */, D22378FC27A7B8DA002D5C6D /* XCConfig.h */, D22378FD27A7B8DA002D5C6D /* XCConfig.m */, @@ -1815,6 +1928,14 @@ path = BlinkConfigTests; sourceTree = ""; }; + BDF40FEA2C14A6CE00DF41C1 /* AgentSettings */ = { + isa = PBXGroup; + children = ( + BDF40FE92C14A6CE00DF41C1 /* AgentSettingsView.swift */, + ); + path = AgentSettings; + sourceTree = ""; + }; C989E53B1D6CC488003E0079 /* BKHosts */ = { isa = PBXGroup; children = ( @@ -1886,6 +2007,7 @@ C9B2E00F1D6B612300B89F69 /* BKTheme.h */, C9B2E0101D6B612300B89F69 /* BKTheme.m */, D28C357526A56DDE00202C4D /* FileProviderDomain.swift */, + BDB129F22D077EB3006970A1 /* BookmarkedLocationsManager.swift */, ); path = Model; sourceTree = ""; @@ -1906,6 +2028,8 @@ C9B2E0141D6B612300B89F69 /* ViewControllers */ = { isa = PBXGroup; children = ( + BDF40FEA2C14A6CE00DF41C1 /* AgentSettings */, + BDB129F92D08B4DE006970A1 /* BookmarkedLocations */, D21076992A69231D00B3D77E /* Snippets */, D2B788862949E8A400F19E4F /* Build */, D2AD8E8527A2C81900DED28D /* Subscriptions */, @@ -2037,7 +2161,6 @@ D241CBCC23040734003D64A5 /* KBKeyValue.swift */, D241CBC623040733003D64A5 /* KBLayout.swift */, D241CBC423040733003D64A5 /* KBSection.swift */, - D241CBC123040733003D64A5 /* KBSound.swift */, D241CBC823040733003D64A5 /* KBTraits.swift */, D241CBC223040733003D64A5 /* KBView.swift */, D2A5221D230D279B0010AC04 /* SmarterTermInput.swift */, @@ -2076,7 +2199,6 @@ D264D2AF28F84592002B1B14 /* GridView.swift */, D264D2B128F84592002B1B14 /* Models.swift */, D264D2B028F84592002B1B14 /* UnavailErrorView.swift */, - D264D2B728F96C66002B1B14 /* WhatsNewSceneDelegate.swift */, D23890BC2900175100B5CEA6 /* FeatureColorPalette.swift */, BD74A7A6290061DE00ED01CF /* WhatsNewInfo.swift */, ); @@ -2086,13 +2208,15 @@ D265FBBB2317DD3C0017EAC4 /* BlinkTests */ = { isa = PBXGroup; children = ( - D265FBBE2317DD3C0017EAC4 /* Info.plist */, + BD9EA215271F83B400874007 /* BlinkLoggingTests.swift */, D20CBA56236031D700D93301 /* CompleteUtilsTests.swift */, - D265FBC42317E5090017EAC4 /* SessionParamsTests.swift */, + BDE7C45B29DCAEFA005E033E /* FileLocationPathTests.swift */, + BD19DB402B056E9C003A4367 /* SSHCommandTest.swift */, + D265FBBE2317DD3C0017EAC4 /* Info.plist */, BD8BBF0825F819970084705F /* SEKeyTests.swift */, - BD9EA215271F83B400874007 /* BlinkLoggingTests.swift */, + D265FBC42317E5090017EAC4 /* SessionParamsTests.swift */, BD74A7C12905BD5800ED01CF /* WhatsNewModelTests.swift */, - BDE7C45B29DCAEFA005E033E /* FileLocationPathTests.swift */, + BD33F7862AAA7C4300CD16EE /* MoshBootstrapTests.swift */, ); path = BlinkTests; sourceTree = ""; @@ -2130,25 +2254,16 @@ D2AD8E6627A2BAFA00DED28D /* PurchasesUserModel.swift */, D2AD8E6727A2BAFA00DED28D /* Purchases.swift */, BDACC7742A6F100D00D0B261 /* TrialNotification.swift */, - D2AD8E6927A2BAFA00DED28D /* Paywall */, + D2CC13B629C05EE7008C71FA /* Intro.swift */, + BD2C754D2D75FA7F007BFE77 /* Walkthrough.swift */, + BDFA97342D959BC90020BBF2 /* TypingText.swift */, ); path = Subscriptions; sourceTree = ""; }; - D2AD8E6927A2BAFA00DED28D /* Paywall */ = { - isa = PBXGroup; - children = ( - D2AD8E6E27A2BAFA00DED28D /* PurchasePageView.swift */, - D2AD8E6F27A2BAFA00DED28D /* CheckmarkRow.swift */, - ); - path = Paywall; - sourceTree = ""; - }; D2AD8E8527A2C81900DED28D /* Subscriptions */ = { isa = PBXGroup; children = ( - D264D2AC28F81DD4002B1B14 /* EarlyFeaturesAccessLetterView.swift */, - D2AD8E8727A2C81900DED28D /* ExplanationView.swift */, D2B788842949C53100F19E4F /* BuildView.swift */, ); path = Subscriptions; @@ -2233,14 +2348,6 @@ path = General; sourceTree = ""; }; - D2E4F92A20B2BB4500B30F7B /* Products */ = { - isa = PBXGroup; - children = ( - D2E4F92E20B2BB4500B30F7B /* curl_ios_static.framework */, - ); - name = Products; - sourceTree = ""; - }; D2E670112A2DB83200DEC4ED /* AppFont */ = { isa = PBXGroup; children = ( @@ -2253,16 +2360,15 @@ D2F330C520A6C8E20074ADD7 /* Commands */ = { isa = PBXGroup; children = ( + BD33F77F2AAA426D00CD16EE /* mosh */, 07FAB8E925C8E6C500E1CC2C /* ssh */, D2334D1221495DAE00D26AC3 /* udptunnel */, - D240806020BC8DF800F30099 /* tool_main.c */, D2F330D320A6F1DF0074ADD7 /* clear.m */, D2F330CB20A6D98C0074ADD7 /* config.m */, D264D2B528F84724002B1B14 /* whatsnew.m */, D2F330C920A6CB840074ADD7 /* help.m */, D263A7672AA9AFE7001C6CFC /* device_info.m */, D2F330D920A7127B0074ADD7 /* open.m */, - D2EFE1EE20B7FAFC0087888B /* link_files.m */, D2F330D520A6F4F50074ADD7 /* history.m */, D23742C921106ADF00366359 /* bench.m */, D2179F2E2136DBC600B0850A /* geo.m */, @@ -2286,6 +2392,7 @@ EA0BA1821C0CC57B00719C1A = { isa = PBXGroup; children = ( + BDEEE36B2B8951D3003003FD /* get_frameworks.sh */, D23FFC69261C2D46003E9227 /* template_setup.xcconfig */, D27D01232615F1BD00128C23 /* developer_setup.xcconfig */, D2C243F7238E44960082C69C /* KB */, @@ -2297,8 +2404,10 @@ 07FABBB025C9AECF00E1CC2C /* BlinkFiles */, 07FABBBD25C9AECF00E1CC2C /* BlinkFilesTests */, D218068825CC277900B98902 /* AppKitBridge */, - BD9EA1CA2718E19000874007 /* BlinkFileProviderUI */, - 98271251262E4BDB00F883FA /* BlinkFileProvider */, + BD9513E22B4F5A7A00A7BEBE /* BlinkFileProvider */, + BD9513F02B4F5A7A00A7BEBE /* BlinkFileProviderTests */, + BD9EA1CA2718E19000874007 /* BlinkFileProviderExtensionUI */, + 98271251262E4BDB00F883FA /* BlinkFileProviderExtension */, BDCB715F268E1577007D7047 /* BlinkConfig */, BDD6D13827594BF900E76F1F /* BlinkConfigTests */, BDBFA3062728914F00C77798 /* BlinkCode */, @@ -2308,6 +2417,8 @@ 0732F04F1D062BB300AB5438 /* Frameworks */, 0732F0481D062B9A00AB5438 /* Resources */, 074F30781D062A2800A73445 /* main.m */, + BD23B6E02CB0585B0041C38D /* ios_patches.m */, + BD23B6DF2CB058550041C38D /* ios_patches.h */, 07E4C5201C935E28000C571A /* Media.xcassets */, 07F670621D05EEE200C0A53C /* Sessions */, 0716B5231CFFAB9300268B5B /* Blink */, @@ -2327,14 +2438,16 @@ 07FABBAF25C9AECF00E1CC2C /* BlinkFiles.framework */, 07FABBB725C9AECF00E1CC2C /* BlinkFilesTests.xctest */, D218068725CC277900B98902 /* AppKitBridge.bundle */, - 98271250262E4BDB00F883FA /* BlinkFileProvider.appex */, + 98271250262E4BDB00F883FA /* BlinkFileProviderExtension.appex */, BDCB715E268E1577007D7047 /* BlinkConfig.framework */, - BD9EA1C92718E19000874007 /* BlinkFileProviderUI.appex */, + BD9EA1C92718E19000874007 /* BlinkFileProviderExtensionUI.appex */, BDBFA3052728914F00C77798 /* BlinkCode.framework */, BDBFA30C2728914F00C77798 /* BlinkCodeTests.xctest */, BDD6D13727594BF900E76F1F /* BlinkConfigTests.xctest */, D22277CE2A26115300D4C708 /* BlinkSnippets.framework */, D22277D52A26115300D4C708 /* BlinkSnippetsTests.xctest */, + BD9513E12B4F5A7A00A7BEBE /* BlinkFileProvider.framework */, + BD9513EA2B4F5A7A00A7BEBE /* BlinkFileProviderTests.xctest */, ); name = Products; sourceTree = ""; @@ -2358,6 +2471,14 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + BD9513DC2B4F5A7A00A7BEBE /* Headers */ = { + isa = PBXHeadersBuildPhase; + buildActionMask = 2147483647; + files = ( + BD9513F32B4F5A7A00A7BEBE /* BlinkFileProvider.h in Headers */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; BDBFA3002728914F00C77798 /* Headers */ = { isa = PBXHeadersBuildPhase; buildActionMask = 2147483647; @@ -2467,9 +2588,9 @@ productReference = 07FABBB725C9AECF00E1CC2C /* BlinkFilesTests.xctest */; productType = "com.apple.product-type.bundle.unit-test"; }; - 9827124F262E4BDB00F883FA /* BlinkFileProvider */ = { + 9827124F262E4BDB00F883FA /* BlinkFileProviderExtension */ = { isa = PBXNativeTarget; - buildConfigurationList = 9827126F262E4BDB00F883FA /* Build configuration list for PBXNativeTarget "BlinkFileProvider" */; + buildConfigurationList = 9827126F262E4BDB00F883FA /* Build configuration list for PBXNativeTarget "BlinkFileProviderExtension" */; buildPhases = ( 9827124C262E4BDB00F883FA /* Sources */, 9827124D262E4BDB00F883FA /* Frameworks */, @@ -2478,20 +2599,61 @@ buildRules = ( ); dependencies = ( - 98E7D0E7263971C000758CF9 /* PBXTargetDependency */, - BDF471BD268CD17B00A7A41B /* PBXTargetDependency */, - BDCB7187268E160F007D7047 /* PBXTargetDependency */, + BD9514232B4F600800A7BEBE /* PBXTargetDependency */, + ); + name = BlinkFileProviderExtension; + packageProductDependencies = ( + ); + productName = BlinkFileProviderExtension; + productReference = 98271250262E4BDB00F883FA /* BlinkFileProviderExtension.appex */; + productType = "com.apple.product-type.app-extension"; + }; + BD9513E02B4F5A7A00A7BEBE /* BlinkFileProvider */ = { + isa = PBXNativeTarget; + buildConfigurationList = BD9513F82B4F5A7A00A7BEBE /* Build configuration list for PBXNativeTarget "BlinkFileProvider" */; + buildPhases = ( + BD9513DC2B4F5A7A00A7BEBE /* Headers */, + BD9513DD2B4F5A7A00A7BEBE /* Sources */, + BD9513DE2B4F5A7A00A7BEBE /* Frameworks */, + BD9513DF2B4F5A7A00A7BEBE /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + BD9514142B4F5F0900A7BEBE /* PBXTargetDependency */, + BD9514182B4F5F0900A7BEBE /* PBXTargetDependency */, + BD95141C2B4F5F0900A7BEBE /* PBXTargetDependency */, ); name = BlinkFileProvider; packageProductDependencies = ( + BD4DBAF92CADA82700538194 /* SQLite */, ); productName = BlinkFileProvider; - productReference = 98271250262E4BDB00F883FA /* BlinkFileProvider.appex */; - productType = "com.apple.product-type.app-extension"; + productReference = BD9513E12B4F5A7A00A7BEBE /* BlinkFileProvider.framework */; + productType = "com.apple.product-type.framework"; }; - BD9EA1C82718E19000874007 /* BlinkFileProviderUI */ = { + BD9513E92B4F5A7A00A7BEBE /* BlinkFileProviderTests */ = { isa = PBXNativeTarget; - buildConfigurationList = BD9EA1D42718E19000874007 /* Build configuration list for PBXNativeTarget "BlinkFileProviderUI" */; + buildConfigurationList = BD9513FB2B4F5A7A00A7BEBE /* Build configuration list for PBXNativeTarget "BlinkFileProviderTests" */; + buildPhases = ( + BD9513E62B4F5A7A00A7BEBE /* Sources */, + BD9513E72B4F5A7A00A7BEBE /* Frameworks */, + BD9513E82B4F5A7A00A7BEBE /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + BD9513ED2B4F5A7A00A7BEBE /* PBXTargetDependency */, + BD9513EF2B4F5A7A00A7BEBE /* PBXTargetDependency */, + ); + name = BlinkFileProviderTests; + productName = BlinkFileProviderTests; + productReference = BD9513EA2B4F5A7A00A7BEBE /* BlinkFileProviderTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + BD9EA1C82718E19000874007 /* BlinkFileProviderExtensionUI */ = { + isa = PBXNativeTarget; + buildConfigurationList = BD9EA1D42718E19000874007 /* Build configuration list for PBXNativeTarget "BlinkFileProviderExtensionUI" */; buildPhases = ( BD9EA1C52718E19000874007 /* Sources */, BD9EA1C62718E19000874007 /* Frameworks */, @@ -2501,9 +2663,9 @@ ); dependencies = ( ); - name = BlinkFileProviderUI; - productName = BlinkFileProviderUI; - productReference = BD9EA1C92718E19000874007 /* BlinkFileProviderUI.appex */; + name = BlinkFileProviderExtensionUI; + productName = BlinkFileProviderExtensionUI; + productReference = BD9EA1C92718E19000874007 /* BlinkFileProviderExtensionUI.appex */; productType = "com.apple.product-type.app-extension"; }; BDBFA3042728914F00C77798 /* BlinkCode */ = { @@ -2686,6 +2848,7 @@ BD9EA1D22718E19000874007 /* PBXTargetDependency */, D2A9B2FA272E6F26009FCBDE /* PBXTargetDependency */, D22277E02A26115300D4C708 /* PBXTargetDependency */, + BDECEA492CDE7544007BBCF6 /* PBXTargetDependency */, ); name = Blink; packageProductDependencies = ( @@ -2699,6 +2862,7 @@ D222780B2A26216900D4C708 /* TreeSitterBashRunestone */, D222780E2A26217A00D4C708 /* Runestone */, BD792A4E2A3BDAAA009EE35F /* ZIPFoundation */, + BDFA97312D7F49EF0020BBF2 /* ConfettiSwiftUI */, ); productName = Blink; productReference = EA0BA18B1C0CC57B00719C1A /* Blink.app */; @@ -2711,7 +2875,7 @@ isa = PBXProject; attributes = { CLASSPREFIX = ""; - LastSwiftUpdateCheck = 1430; + LastSwiftUpdateCheck = 1500; LastUpgradeCheck = 0920; ORGANIZATIONNAME = "Carlos Cabañero Projects SL"; TargetAttributes = { @@ -2733,6 +2897,13 @@ 9827124F262E4BDB00F883FA = { CreatedOnToolsVersion = 12.4; }; + BD9513E02B4F5A7A00A7BEBE = { + CreatedOnToolsVersion = 15.0.1; + }; + BD9513E92B4F5A7A00A7BEBE = { + CreatedOnToolsVersion = 15.0.1; + TestTargetID = EA0BA18A1C0CC57B00719C1A; + }; BD9EA1C82718E19000874007 = { CreatedOnToolsVersion = 13.0; }; @@ -2817,14 +2988,12 @@ D22278062A26216900D4C708 /* XCRemoteSwiftPackageReference "treesitterlanguages" */, D222780D2A26217A00D4C708 /* XCRemoteSwiftPackageReference "Runestone" */, BD792A4D2A3BDAAA009EE35F /* XCRemoteSwiftPackageReference "ZIPFoundation" */, + BD4DBAF82CADA82700538194 /* XCRemoteSwiftPackageReference "SQLite" */, + BDA35C972D7F4955004CD75C /* XCRemoteSwiftPackageReference "ConfettiSwiftUI" */, ); productRefGroup = EA0BA18C1C0CC57B00719C1A /* Products */; projectDirPath = ""; projectReferences = ( - { - ProductGroup = D2E4F92A20B2BB4500B30F7B /* Products */; - ProjectRef = D2E4F92920B2BB4500B30F7B /* curl_ios_static.xcodeproj */; - }, { ProductGroup = 0732F1061D062BF700AB5438 /* Products */; ProjectRef = 0732F1051D062BF700AB5438 /* MBProgressHUD.xcodeproj */; @@ -2839,14 +3008,16 @@ 07FABBAE25C9AECF00E1CC2C /* BlinkFiles */, 07FABBB625C9AECF00E1CC2C /* BlinkFilesTests */, D218068625CC277900B98902 /* AppKitBridge */, - 9827124F262E4BDB00F883FA /* BlinkFileProvider */, + 9827124F262E4BDB00F883FA /* BlinkFileProviderExtension */, BDCB715D268E1577007D7047 /* BlinkConfig */, - BD9EA1C82718E19000874007 /* BlinkFileProviderUI */, + BD9EA1C82718E19000874007 /* BlinkFileProviderExtensionUI */, BDBFA3042728914F00C77798 /* BlinkCode */, BDBFA30B2728914F00C77798 /* BlinkCodeTests */, BDD6D13627594BF900E76F1F /* BlinkConfigTests */, D22277CD2A26115300D4C708 /* BlinkSnippets */, D22277D42A26115300D4C708 /* BlinkSnippetsTests */, + BD9513E02B4F5A7A00A7BEBE /* BlinkFileProvider */, + BD9513E92B4F5A7A00A7BEBE /* BlinkFileProviderTests */, ); }; /* End PBXProject section */ @@ -2873,13 +3044,6 @@ remoteRef = D2A3F4DC206909A8006BB305 /* PBXContainerItemProxy */; sourceTree = BUILT_PRODUCTS_DIR; }; - D2E4F92E20B2BB4500B30F7B /* curl_ios_static.framework */ = { - isa = PBXReferenceProxy; - fileType = wrapper.framework; - path = curl_ios_static.framework; - remoteRef = D2E4F92D20B2BB4500B30F7B /* PBXContainerItemProxy */; - sourceTree = BUILT_PRODUCTS_DIR; - }; /* End PBXReferenceProxy section */ /* Begin PBXResourcesBuildPhase section */ @@ -2924,6 +3088,20 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + BD9513DF2B4F5A7A00A7BEBE /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + BD9513E82B4F5A7A00A7BEBE /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; BD9EA1C72718E19000874007 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -3032,6 +3210,7 @@ 0732F04E1D062B9A00AB5438 /* term.html in Resources */, 07E3AED11D9191B4007BC086 /* blink.png in Resources */, D21A3FE721943BE200269705 /* dark-app-ipad-76pt@2x.png in Resources */, + BDE84C3C2BAE335100457391 /* vim in Resources */, C9B2E02F1D6B612400B89F69 /* Settings.storyboard in Resources */, D2496F3F20038B3300E75FE9 /* hterm_all.min.js in Resources */, D21A3FDB21943BE200269705 /* dark-settings-ipad-29pt@2x.png in Resources */, @@ -3112,16 +3291,35 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - BD9EA20D271F664D00874007 /* Publisher.swift in Sources */, - BD44DCE626D6BEAC00054338 /* BlinkItemIdentifier.swift in Sources */, - BD9EA1802718D6C400874007 /* NSFileProviderError.swift in Sources */, - BD2E27B529BAA8DA003AF1DA /* ReplaySubject.swift in Sources */, - 98271253262E4BDB00F883FA /* FileProviderExtension.swift in Sources */, - BD8DB62A279B1EC800497C88 /* SSHClient.swift in Sources */, - BD9EA20B271F62ED00874007 /* BlinkLogging.swift in Sources */, - BD896F7B26CEAD37004313E6 /* FileTranslatorCache.swift in Sources */, - 98E7D0BE2638B46400758CF9 /* BlinkItemReference.swift in Sources */, - 98271257262E4BDB00F883FA /* FileProviderEnumerator.swift in Sources */, + BD95141F2B4F5F4000A7BEBE /* main.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + BD9513DD2B4F5A7A00A7BEBE /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + BD9514282B4F60F600A7BEBE /* BlinkLogging.swift in Sources */, + BD9514312B4F660B00A7BEBE /* FileProviderReplicatedExtension.swift in Sources */, + BD9514292B4F60F600A7BEBE /* Publisher.swift in Sources */, + BD9514332B4F661600A7BEBE /* FileProviderItem.swift in Sources */, + BDBA46CD2CD2EDD900AB44A0 /* FileProviderReplicatedExtension+Helpers.swift in Sources */, + BD4DBAFC2CADB3AF00538194 /* WorkingSetDatabase.swift in Sources */, + BD9514352B4F662100A7BEBE /* FileProviderReplicatedEnumerator.swift in Sources */, + BD9514002B4F5B5600A7BEBE /* ReplaySubject.swift in Sources */, + BDF041142CA363F0005B7138 /* FilesTranslatorConnection.swift in Sources */, + BD95140C2B4F5B9400A7BEBE /* NSFileProviderError.swift in Sources */, + BD95140A2B4F5B9400A7BEBE /* SSHClient.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + BD9513E62B4F5A7A00A7BEBE /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + BD4F7A072CBC9FD300FE67EA /* FileTranslatorConnectionTests.swift in Sources */, + BD9513F22B4F5A7A00A7BEBE /* BlinkFileProviderTests.swift in Sources */, + BD4F79F82CB4975E00FE67EA /* WorkingSetDatabaseTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -3141,8 +3339,8 @@ BD81522E2739A91D002BB169 /* Publisher.swift in Sources */, BD8DB648279B512900497C88 /* CodeFileSystemService.swift in Sources */, BD835DD427A0BD19002C37D7 /* ReplaySubject.swift in Sources */, - BD8DB642279B2FA200497C88 /* SSHClient.swift in Sources */, BD67FC79272B30F300C1EE75 /* Messages.swift in Sources */, + BD9514272B4F606500A7BEBE /* SSHClient.swift in Sources */, BDBFA3212728925C00C77798 /* WebSocketServer.swift in Sources */, BD67FC9B2732D4D300C1EE75 /* BackgroundTaskMonitor.swift in Sources */, BD81522027387D1F002BB169 /* Certificates.swift in Sources */, @@ -3164,7 +3362,6 @@ buildActionMask = 2147483647; files = ( BDD6D149275951D900E76F1F /* BKGlobalSSHConfig.swift in Sources */, - D2DE9CA626EA1CC000A0B29C /* BKMiniLog.m in Sources */, BDCB7179268E15A2007D7047 /* BKHosts.m in Sources */, BDCB7182268E15A2007D7047 /* SEKey.swift in Sources */, BDCB7178268E15A2007D7047 /* BKHosts.swift in Sources */, @@ -3231,7 +3428,9 @@ D265FBC52317E5090017EAC4 /* SessionParamsTests.swift in Sources */, BD9EA218271F846400874007 /* Publisher.swift in Sources */, BD8BBF5525F829B00084705F /* SEKeyTests.swift in Sources */, + BD33F7872AAA7C4300CD16EE /* MoshBootstrapTests.swift in Sources */, BD9EA216271F83B400874007 /* BlinkLoggingTests.swift in Sources */, + BD19DB412B056E9C003A4367 /* SSHCommandTest.swift in Sources */, D20CBA57236031D700D93301 /* CompleteUtilsTests.swift in Sources */, D20CBA5B2360327900D93301 /* CompleteUtils.swift in Sources */, D265FBC62317E54C0017EAC4 /* SessionParams.swift in Sources */, @@ -3242,8 +3441,9 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - D2AD8E7D27A2BAFA00DED28D /* CheckmarkRow.swift in Sources */, + BD23B6E12CB0585B0041C38D /* ios_patches.m in Sources */, D2AD8E7527A2BAFA00DED28D /* PurchasesUserModel.swift in Sources */, + BDFA97352D959BC90020BBF2 /* TypingText.swift in Sources */, BD9EA212271F824900874007 /* Publisher.swift in Sources */, D241CBDF23040734003D64A5 /* KBKeyAccessibilityElement.swift in Sources */, D2DE0DDE260331F300A69B6F /* NewKeyView.swift in Sources */, @@ -3253,6 +3453,7 @@ D241CBD923040734003D64A5 /* KBKeyView.swift in Sources */, 803B99D72582869200DC99C8 /* BKNotificationsView.swift in Sources */, BD8152542743FF84002BB169 /* skstore.swift in Sources */, + BD818A052AAFC18400956488 /* mosh.swift in Sources */, C94E9B631D6BA21C00DA4DD6 /* DismissSegue.m in Sources */, D29B4A92274D206C00C66ED9 /* BrowserController.swift in Sources */, 803B99E3258381B200DC99C8 /* SettingsHostingController.swift in Sources */, @@ -3263,7 +3464,6 @@ 07F670731D05EEE200C0A53C /* MoshSession.m in Sources */, 07F670721D05EEE200C0A53C /* MCPSession.m in Sources */, D23742CA21106ADF00366359 /* bench.m in Sources */, - D240806120BC8DF800F30099 /* tool_main.c in Sources */, D210769B2A69234500B3D77E /* SnippetsConfigView.swift in Sources */, D22277FD2A26204900D4C708 /* SearchMode.swift in Sources */, B7D6A6291E2D43A800EDF7B0 /* BKSmartKeysConfigViewController.m in Sources */, @@ -3278,9 +3478,9 @@ D2179F2D2136A5DC00B0850A /* GeoManager.m in Sources */, D2C2441E238E44AB0082C69C /* Checkmark.swift in Sources */, D2B1F8DD23265A0600634D67 /* CommandsHUDView.swift in Sources */, - D264D2B828F96C66002B1B14 /* WhatsNewSceneDelegate.swift in Sources */, D2903F3C239BBF5D005F991B /* KeyShortcut.swift in Sources */, D27BBA1C20529FFF00AEA303 /* TermStream.m in Sources */, + BDB129F32D077EB3006970A1 /* BookmarkedLocationsManager.swift in Sources */, D22277FB2A26204900D4C708 /* SnippetEditingView.swift in Sources */, D2036B6323967F2F0013D2A3 /* BindingConfigView.swift in Sources */, D25DE9C12939EB36008246EB /* NonStdIO+ArgumentParser.swift in Sources */, @@ -3292,13 +3492,11 @@ D27D0118261202A400128C23 /* KeyUIError.swift in Sources */, 07FAB8F425C8E6C500E1CC2C /* SSHConfig.swift in Sources */, D2CF27292428A791009885ED /* KBTracker.swift in Sources */, - D264D2AD28F81DD4002B1B14 /* EarlyFeaturesAccessLetterView.swift in Sources */, 07F670771D05EEE200C0A53C /* SSHSession.m in Sources */, - D241CBD323040734003D64A5 /* KBSound.swift in Sources */, 07F670761D05EEE200C0A53C /* SSHCopyIDSession.m in Sources */, D264D2B428F84592002B1B14 /* Models.swift in Sources */, D22B16D828CF6ED20004EEC1 /* NewPasskeyView.swift in Sources */, - BD98AC95260BE20000B4E6A1 /* SSHAgentPool.swift in Sources */, + BD98AC95260BE20000B4E6A1 /* SSHDefaultAgent.swift in Sources */, D2C244352390FEEF0082C69C /* KeyBindingAction.swift in Sources */, D241CBDA23040734003D64A5 /* KBTraits.swift in Sources */, B752EE2B1DFEF19D00E305C8 /* BKUserConfigurationManager.m in Sources */, @@ -3308,14 +3506,15 @@ D241CBD823040734003D64A5 /* KBLayout.swift in Sources */, D2B1F8DF23265A4700634D67 /* CommandControl.swift in Sources */, D2A52227231304FF0010AC04 /* UIGestureRecognizer.swift in Sources */, - D2AD8E7C27A2BAFA00DED28D /* PurchasePageView.swift in Sources */, D22278012A26204900D4C708 /* EditorViewController.swift in Sources */, + BD818A152AB3A40100956488 /* MoshClientParams.swift in Sources */, D266A9DC272A77A100C85EED /* code.swift in Sources */, D2C24425238E44AB0082C69C /* KeyModifierPicker.swift in Sources */, D259479C269C671F008B5305 /* MoshCustomOptionsPickerView.swift in Sources */, D259479C269C671F008B5305 /* MoshCustomOptionsPickerView.swift in Sources */, 07FAB8F025C8E6C500E1CC2C /* Helpers.swift in Sources */, C989E5561D6CC4A1003E0079 /* BKAppearanceViewController.m in Sources */, + BD88697B2CF4DDAD00F71119 /* 1810Migration.swift in Sources */, D2E5454A27C4D422002635A2 /* SupportView.swift in Sources */, D2D8DD9823C8506900BFF223 /* LockView.swift in Sources */, B7A487691DC7C48D007BA809 /* UIDevice+DeviceName.m in Sources */, @@ -3325,6 +3524,7 @@ BD9EA1FE271A148700874007 /* Migrator.swift in Sources */, D2C24417238E44AB0082C69C /* KeyConfig.swift in Sources */, D28F301A21AD8A6B00E5259F /* DeviceInfo.m in Sources */, + BD33F7822AAA426D00CD16EE /* MoshBootstrap.swift in Sources */, D2179F2F2136DBC600B0850A /* geo.m in Sources */, D2C24414238E44AB0082C69C /* KeyAction.swift in Sources */, D28B0337243EF5F2008F38F6 /* Set+UIScene.swift in Sources */, @@ -3343,18 +3543,20 @@ D2C24419238E44AB0082C69C /* KeyConfigPairView.swift in Sources */, D2AB611E23AB5ACD00BE6585 /* UIApplication+Version.m in Sources */, 07FAB8F525C8E6C500E1CC2C /* SSHConfigProvider.swift in Sources */, + BD2C754E2D75FA85007BFE77 /* Walkthrough.swift in Sources */, D241CBD523040734003D64A5 /* KBKey.swift in Sources */, D215AC30233521B600E164C3 /* WKWebView.swift in Sources */, D21DEE46260CB03900D8E640 /* PassphraseView.swift in Sources */, D27AD9BC222FDD3D00379872 /* xcall.m in Sources */, + BDB129FA2D08B4DE006970A1 /* BookmarkedLocationsView.swift in Sources */, D29568B921BE629100480A83 /* bk_getopts.c in Sources */, D276AB0D28D1D36200950728 /* NewSecurityKeyView.swift in Sources */, 07E3AEC61D9190CF007BC086 /* BKAboutViewController.m in Sources */, D2F330D620A6F4F50074ADD7 /* history.m in Sources */, BD74A7A7290061DE00ED01CF /* WhatsNewInfo.swift in Sources */, D264D2B228F84592002B1B14 /* GridView.swift in Sources */, - D2EFE1F520B7FAFC0087888B /* link_files.m in Sources */, D2A54CB129801062009D79FE /* BuildAccountModel.swift in Sources */, + BD818A0C2AB120B800956488 /* MoshServerParams.swift in Sources */, D2C24418238E44AB0082C69C /* KBConfigView.swift in Sources */, D2BF5F7F265BA0A80070F839 /* UserDefaults.swift in Sources */, D2F330CA20A6CB840074ADD7 /* help.m in Sources */, @@ -3391,6 +3593,7 @@ D23EA9592604CB4C00BCF1FF /* FixedTextField.swift in Sources */, D2C24437239104250082C69C /* ShortcutsConfigView.swift in Sources */, B7D450361DD3A87200CE0DBE /* BKiCloudSyncHandler.m in Sources */, + BD818A132AB3865F00956488 /* MoshCommand.swift in Sources */, D248E67622DDDF130057FE67 /* UIStateRestorable.swift in Sources */, C9B2E0341D6B612400B89F69 /* BKTheme.m in Sources */, D241CBD123040734003D64A5 /* KBKeyViewArrows.swift in Sources */, @@ -3422,7 +3625,6 @@ D2887A5622DC676F00701BD5 /* SpaceController.swift in Sources */, D2AC6749220303D900177BC5 /* openurl.m in Sources */, D27DD70A2A3B203F009E97BC /* NewSnippetViewController.swift in Sources */, - D2AD8E8927A2C81900DED28D /* ExplanationView.swift in Sources */, D25DE9C02939EB36008246EB /* NonStdIO.swift in Sources */, D2D3B04828FD7A790086F633 /* EmptyStateView.swift in Sources */, D2CB353B23339D5C00088765 /* UIView+Touches.swift in Sources */, @@ -3437,6 +3639,7 @@ D22277FE2A26204900D4C708 /* SnippetView.swift in Sources */, D2499BEC2362EFD40009C701 /* cpp.cpp in Sources */, D264D2B328F84592002B1B14 /* UnavailErrorView.swift in Sources */, + BDF40FEB2C14A6CE00DF41C1 /* AgentSettingsView.swift in Sources */, D2B788852949C53100F19E4F /* BuildView.swift in Sources */, D20CBA5A2360324100D93301 /* CompleteUtils.swift in Sources */, D265FBC9231905AC0017EAC4 /* NSCoder+CodingKey.swift in Sources */, @@ -3499,14 +3702,9 @@ 9827126A262E4BDB00F883FA /* PBXTargetDependency */ = { isa = PBXTargetDependency; platformFilter = ios; - target = 9827124F262E4BDB00F883FA /* BlinkFileProvider */; + target = 9827124F262E4BDB00F883FA /* BlinkFileProviderExtension */; targetProxy = 98271269262E4BDB00F883FA /* PBXContainerItemProxy */; }; - 98E7D0E7263971C000758CF9 /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = 07FABBAE25C9AECF00E1CC2C /* BlinkFiles */; - targetProxy = 98E7D0E6263971C000758CF9 /* PBXContainerItemProxy */; - }; BD67FC84272DF22700C1EE75 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = BDCB715D268E1577007D7047 /* BlinkConfig */; @@ -3517,9 +3715,39 @@ target = 07FABB8325C9AEC000E1CC2C /* SSH */; targetProxy = BD67FC87272E3FF800C1EE75 /* PBXContainerItemProxy */; }; + BD9513ED2B4F5A7A00A7BEBE /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = BD9513E02B4F5A7A00A7BEBE /* BlinkFileProvider */; + targetProxy = BD9513EC2B4F5A7A00A7BEBE /* PBXContainerItemProxy */; + }; + BD9513EF2B4F5A7A00A7BEBE /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = EA0BA18A1C0CC57B00719C1A /* Blink */; + targetProxy = BD9513EE2B4F5A7A00A7BEBE /* PBXContainerItemProxy */; + }; + BD9514142B4F5F0900A7BEBE /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = BDCB715D268E1577007D7047 /* BlinkConfig */; + targetProxy = BD9514132B4F5F0900A7BEBE /* PBXContainerItemProxy */; + }; + BD9514182B4F5F0900A7BEBE /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 07FABBAE25C9AECF00E1CC2C /* BlinkFiles */; + targetProxy = BD9514172B4F5F0900A7BEBE /* PBXContainerItemProxy */; + }; + BD95141C2B4F5F0900A7BEBE /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 07FABB8325C9AEC000E1CC2C /* SSH */; + targetProxy = BD95141B2B4F5F0900A7BEBE /* PBXContainerItemProxy */; + }; + BD9514232B4F600800A7BEBE /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = BD9513E02B4F5A7A00A7BEBE /* BlinkFileProvider */; + targetProxy = BD9514222B4F600800A7BEBE /* PBXContainerItemProxy */; + }; BD9EA1D22718E19000874007 /* PBXTargetDependency */ = { isa = PBXTargetDependency; - target = BD9EA1C82718E19000874007 /* BlinkFileProviderUI */; + target = BD9EA1C82718E19000874007 /* BlinkFileProviderExtensionUI */; targetProxy = BD9EA1D12718E19000874007 /* PBXContainerItemProxy */; }; BDBFA30F2728914F00C77798 /* PBXTargetDependency */ = { @@ -3537,11 +3765,6 @@ target = BDCB715D268E1577007D7047 /* BlinkConfig */; targetProxy = BDCB7163268E1577007D7047 /* PBXContainerItemProxy */; }; - BDCB7187268E160F007D7047 /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = BDCB715D268E1577007D7047 /* BlinkConfig */; - targetProxy = BDCB7186268E160F007D7047 /* PBXContainerItemProxy */; - }; BDCB7191268E173D007D7047 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 07FABB8325C9AEC000E1CC2C /* SSH */; @@ -3552,10 +3775,11 @@ target = BDCB715D268E1577007D7047 /* BlinkConfig */; targetProxy = BDD6D13C27594BF900E76F1F /* PBXContainerItemProxy */; }; - BDF471BD268CD17B00A7A41B /* PBXTargetDependency */ = { + BDECEA492CDE7544007BBCF6 /* PBXTargetDependency */ = { isa = PBXTargetDependency; - target = 07FABB8325C9AEC000E1CC2C /* SSH */; - targetProxy = BDF471BC268CD17B00A7A41B /* PBXContainerItemProxy */; + platformFilter = ios; + target = BD9513E02B4F5A7A00A7BEBE /* BlinkFileProvider */; + targetProxy = BDECEA482CDE7544007BBCF6 /* PBXContainerItemProxy */; }; D218069E25CC27C100B98902 /* PBXTargetDependency */ = { isa = PBXTargetDependency; @@ -3960,27 +4184,28 @@ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; - CODE_SIGN_ENTITLEMENTS = BlinkFileProvider/BlinkFileProvider.entitlements; + CODE_SIGN_ENTITLEMENTS = BlinkFileProviderExtension/BlinkFileProviderExtension.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 795; + CURRENT_PROJECT_VERSION = 973; GCC_C_LANGUAGE_STANDARD = gnu11; GCC_PREPROCESSOR_DEFINITIONS = ( "DEBUG=1", "$(inherited)", ); - INFOPLIST_FILE = BlinkFileProvider/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 16.1; + INFOPLIST_FILE = BlinkFileProviderExtension/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = Blink; + IPHONEOS_DEPLOYMENT_TARGET = 17.6; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 16.2.0; + MARKETING_VERSION = 18.1.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; - PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_ID).BlinkFileProvider"; + PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_ID).BlinkFileProviderExtension"; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; SUPPORTS_MACCATALYST = NO; @@ -4005,22 +4230,23 @@ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; - CODE_SIGN_ENTITLEMENTS = BlinkFileProvider/BlinkFileProvider.entitlements; + CODE_SIGN_ENTITLEMENTS = BlinkFileProviderExtension/BlinkFileProviderExtension.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 795; + CURRENT_PROJECT_VERSION = 973; GCC_C_LANGUAGE_STANDARD = gnu11; - INFOPLIST_FILE = BlinkFileProvider/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 16.1; + INFOPLIST_FILE = BlinkFileProviderExtension/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = Blink; + IPHONEOS_DEPLOYMENT_TARGET = 17.6; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 16.2.0; + MARKETING_VERSION = 18.1.0; MTL_FAST_MATH = YES; - PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_ID).BlinkFileProvider"; + PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_ID).BlinkFileProviderExtension"; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; SUPPORTS_MACCATALYST = NO; @@ -4030,6 +4256,196 @@ }; name = Release; }; + BD9513F92B4F5A7A00A7BEBE /* 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_OBJC_WEAK = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_IDENTITY = "iPhone Developer"; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEFINES_MODULE = YES; + DEVELOPMENT_TEAM = A2H2CL32AG; + DYLIB_COMPATIBILITY_VERSION = 1; + DYLIB_CURRENT_VERSION = 1; + DYLIB_INSTALL_NAME_BASE = "@rpath"; + ENABLE_MODULE_VERIFIER = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2024 Carlos Cabañero Projects SL. All rights reserved."; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + IPHONEOS_DEPLOYMENT_TARGET = 17.6; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + MODULE_VERIFIER_SUPPORTED_LANGUAGES = "objective-c objective-c++"; + MODULE_VERIFIER_SUPPORTED_LANGUAGE_STANDARDS = "gnu17 gnu++20"; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_ID).BlinkFileProvider"; + PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + VERSIONING_SYSTEM = "apple-generic"; + VERSION_INFO_PREFIX = ""; + }; + name = Debug; + }; + BD9513FA2B4F5A7A00A7BEBE /* 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_OBJC_WEAK = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_IDENTITY = "iPhone Developer"; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEFINES_MODULE = YES; + DEVELOPMENT_TEAM = A2H2CL32AG; + DYLIB_COMPATIBILITY_VERSION = 1; + DYLIB_CURRENT_VERSION = 1; + DYLIB_INSTALL_NAME_BASE = "@rpath"; + ENABLE_MODULE_VERIFIER = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2024 Carlos Cabañero Projects SL. All rights reserved."; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + IPHONEOS_DEPLOYMENT_TARGET = 17.6; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + MODULE_VERIFIER_SUPPORTED_LANGUAGES = "objective-c objective-c++"; + MODULE_VERIFIER_SUPPORTED_LANGUAGE_STANDARDS = "gnu17 gnu++20"; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_ID).BlinkFileProvider"; + PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + VERSIONING_SYSTEM = "apple-generic"; + VERSION_INFO_PREFIX = ""; + }; + name = Release; + }; + BD9513FC2B4F5A7A00A7BEBE /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + 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_OBJC_WEAK = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = A2H2CL32AG; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.6; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_ID).BlinkFileProviderTests"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Blink.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Blink"; + }; + name = Debug; + }; + BD9513FD2B4F5A7A00A7BEBE /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + 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_OBJC_WEAK = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = A2H2CL32AG; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.6; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_ID).BlinkFileProviderTests"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Blink.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Blink"; + }; + name = Release; + }; BD9EA1D52718E19000874007 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -4046,26 +4462,26 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 791; + CURRENT_PROJECT_VERSION = 973; GCC_C_LANGUAGE_STANDARD = gnu11; GCC_PREPROCESSOR_DEFINITIONS = ( "DEBUG=1", "$(inherited)", ); GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_FILE = BlinkFileProviderUI/Info.plist; + INFOPLIST_FILE = BlinkFileProviderExtensionUI/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = Blink; INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2021 Carlos Cabañero Projects SL. All rights reserved."; - IPHONEOS_DEPLOYMENT_TARGET = 16.1; + IPHONEOS_DEPLOYMENT_TARGET = 17.6; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 16.2.0; + MARKETING_VERSION = 18.1.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; - PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_ID).BlinkFileProviderUI"; + PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_ID).BlinkFileProviderExtensionUI"; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; @@ -4092,21 +4508,21 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 791; + CURRENT_PROJECT_VERSION = 973; GCC_C_LANGUAGE_STANDARD = gnu11; GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_FILE = BlinkFileProviderUI/Info.plist; + INFOPLIST_FILE = BlinkFileProviderExtensionUI/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = Blink; INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2021 Carlos Cabañero Projects SL. All rights reserved."; - IPHONEOS_DEPLOYMENT_TARGET = 16.1; + IPHONEOS_DEPLOYMENT_TARGET = 17.6; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 16.2.0; + MARKETING_VERSION = 18.1.0; MTL_FAST_MATH = YES; - PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_ID).BlinkFileProviderUI"; + PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_ID).BlinkFileProviderExtensionUI"; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; SWIFT_EMIT_LOC_STRINGS = YES; @@ -4707,7 +5123,7 @@ "$(inherited)", ); INFOPLIST_FILE = BlinkTests/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 16.1; + IPHONEOS_DEPLOYMENT_TARGET = 17.6; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -4742,7 +5158,7 @@ CURRENT_PROJECT_VERSION = 543; GCC_C_LANGUAGE_STANDARD = gnu11; INFOPLIST_FILE = BlinkTests/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 16.1; + IPHONEOS_DEPLOYMENT_TARGET = 17.6; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -4875,7 +5291,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 831; + CURRENT_PROJECT_VERSION = 1003; DEAD_CODE_STRIPPING = NO; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEFINES_MODULE = YES; @@ -4887,7 +5303,9 @@ "$(inherited)", ); INFOPLIST_FILE = Blink/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 16.1; + INFOPLIST_KEY_CFBundleDisplayName = "Blink Shell"; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; + IPHONEOS_DEPLOYMENT_TARGET = 17.6; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -4896,7 +5314,7 @@ "$(inherited)", "$(PROJECT_DIR)/Frameworks", ); - MARKETING_VERSION = 17.0.0; + MARKETING_VERSION = 18.3.0; NEW_SETTING = ""; OTHER_CFLAGS = "$(BLINK_OTHER_CFLAGS)"; OTHER_LDFLAGS = ( @@ -4926,14 +5344,16 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 831; + CURRENT_PROJECT_VERSION = 1003; DEAD_CODE_STRIPPING = NO; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = A2H2CL32AG; ENABLE_BITCODE = NO; GCC_PREPROCESSOR_DEFINITIONS = ""; INFOPLIST_FILE = Blink/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 16.1; + INFOPLIST_KEY_CFBundleDisplayName = "Blink Shell"; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; + IPHONEOS_DEPLOYMENT_TARGET = 17.6; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -4942,7 +5362,7 @@ "$(inherited)", "$(PROJECT_DIR)/Frameworks", ); - MARKETING_VERSION = 17.0.0; + MARKETING_VERSION = 18.3.0; NEW_SETTING = ""; OTHER_CFLAGS = "$(BLINK_OTHER_CFLAGS)"; OTHER_LDFLAGS = ( @@ -5000,7 +5420,7 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Debug; }; - 9827126F262E4BDB00F883FA /* Build configuration list for PBXNativeTarget "BlinkFileProvider" */ = { + 9827126F262E4BDB00F883FA /* Build configuration list for PBXNativeTarget "BlinkFileProviderExtension" */ = { isa = XCConfigurationList; buildConfigurations = ( 98271270262E4BDB00F883FA /* Debug */, @@ -5009,7 +5429,25 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Debug; }; - BD9EA1D42718E19000874007 /* Build configuration list for PBXNativeTarget "BlinkFileProviderUI" */ = { + BD9513F82B4F5A7A00A7BEBE /* Build configuration list for PBXNativeTarget "BlinkFileProvider" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + BD9513F92B4F5A7A00A7BEBE /* Debug */, + BD9513FA2B4F5A7A00A7BEBE /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; + BD9513FB2B4F5A7A00A7BEBE /* Build configuration list for PBXNativeTarget "BlinkFileProviderTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + BD9513FC2B4F5A7A00A7BEBE /* Debug */, + BD9513FD2B4F5A7A00A7BEBE /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; + BD9EA1D42718E19000874007 /* Build configuration list for PBXNativeTarget "BlinkFileProviderExtensionUI" */ = { isa = XCConfigurationList; buildConfigurations = ( BD9EA1D52718E19000874007 /* Debug */, @@ -5111,6 +5549,14 @@ /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ + BD4DBAF82CADA82700538194 /* XCRemoteSwiftPackageReference "SQLite" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/stephencelis/SQLite.swift"; + requirement = { + kind = upToNextMinorVersion; + minimumVersion = 0.15.3; + }; + }; BD792A4D2A3BDAAA009EE35F /* XCRemoteSwiftPackageReference "ZIPFoundation" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/weichsel/ZIPFoundation.git"; @@ -5127,6 +5573,14 @@ minimumVersion = 0.2.0; }; }; + BDA35C972D7F4955004CD75C /* XCRemoteSwiftPackageReference "ConfettiSwiftUI" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/simibac/ConfettiSwiftUI.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 2.0.2; + }; + }; D22278062A26216900D4C708 /* XCRemoteSwiftPackageReference "treesitterlanguages" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/simonbs/treesitterlanguages"; @@ -5164,7 +5618,7 @@ repositoryURL = "https://github.com/RevenueCat/purchases-ios.git"; requirement = { kind = upToNextMajorVersion; - minimumVersion = 4.0.0; + minimumVersion = 5.0.0; }; }; /* End XCRemoteSwiftPackageReference section */ @@ -5174,6 +5628,11 @@ isa = XCSwiftPackageProductDependency; productName = ArgumentParser; }; + BD4DBAF92CADA82700538194 /* SQLite */ = { + isa = XCSwiftPackageProductDependency; + package = BD4DBAF82CADA82700538194 /* XCRemoteSwiftPackageReference "SQLite" */; + productName = SQLite; + }; BD792A4E2A3BDAAA009EE35F /* ZIPFoundation */ = { isa = XCSwiftPackageProductDependency; package = BD792A4D2A3BDAAA009EE35F /* XCRemoteSwiftPackageReference "ZIPFoundation" */; @@ -5198,6 +5657,11 @@ isa = XCSwiftPackageProductDependency; productName = SSHConfig; }; + BDFA97312D7F49EF0020BBF2 /* ConfettiSwiftUI */ = { + isa = XCSwiftPackageProductDependency; + package = BDA35C972D7F4955004CD75C /* XCRemoteSwiftPackageReference "ConfettiSwiftUI" */; + productName = ConfettiSwiftUI; + }; D22278072A26216900D4C708 /* TreeSitterBash */ = { isa = XCSwiftPackageProductDependency; package = D22278062A26216900D4C708 /* XCRemoteSwiftPackageReference "treesitterlanguages" */; diff --git a/Blink.xcodeproj/xcshareddata/xcschemes/Blink.xcscheme b/Blink.xcodeproj/xcshareddata/xcschemes/Blink.xcscheme index 2242d554e..2991b10dd 100644 --- a/Blink.xcodeproj/xcshareddata/xcschemes/Blink.xcscheme +++ b/Blink.xcodeproj/xcshareddata/xcschemes/Blink.xcscheme @@ -72,6 +72,17 @@ ReferencedContainer = "container:Blink.xcodeproj"> + + + + @@ -54,6 +54,17 @@ ReferencedContainer = "container:Blink.xcodeproj"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Blink.xcodeproj/xcshareddata/xcschemes/BlinkFileProviderExtensionUI.xcscheme b/Blink.xcodeproj/xcshareddata/xcschemes/BlinkFileProviderExtensionUI.xcscheme new file mode 100644 index 000000000..a6be212c8 --- /dev/null +++ b/Blink.xcodeproj/xcshareddata/xcschemes/BlinkFileProviderExtensionUI.xcscheme @@ -0,0 +1,108 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Blink.xcodeproj/xcshareddata/xcschemes/BlinkFileProviderUI.xcscheme b/Blink.xcodeproj/xcshareddata/xcschemes/BlinkFileProviderUI.xcscheme index 09ec076ec..ae1fe0b09 100644 --- a/Blink.xcodeproj/xcshareddata/xcschemes/BlinkFileProviderUI.xcscheme +++ b/Blink.xcodeproj/xcshareddata/xcschemes/BlinkFileProviderUI.xcscheme @@ -16,8 +16,8 @@ @@ -54,6 +54,17 @@ ReferencedContainer = "container:Blink.xcodeproj"> + + + + + + + + #include -// Thread-local input and output streams -// Note we could not import ios_system -extern __thread FILE* thread_stdin; -extern __thread FILE* thread_stdout; -extern __thread FILE* thread_stderr; -extern __thread void* thread_context; - typedef int socket_t; extern void __thread_ssh_execute_command(const char *command, socket_t in, socket_t out); extern int ios_dup2(int fd1, int fd2); extern void ios_exit(int errorCode) __dead2; // set error code and exits from the thread. +typedef void (*mosh_state_callback) (const void *context, const void *buffer, size_t size); + #import "BLKDefaults.h" #import "UIDevice+DeviceName.h" #import "BKHosts.h" @@ -70,5 +65,7 @@ extern void ios_exit(int errorCode) __dead2; // set error code and exits from th #import "TokioSignals.h" #import "BlinkMenu.h" #import "GeoManager.h" +#import "mosh/moshiosbridge.h" + #endif /* Blink_bridge_h */ diff --git a/Blink/Commands/browse.swift b/Blink/Commands/browse.swift index 7bac4555f..2ae13d6bd 100644 --- a/Blink/Commands/browse.swift +++ b/Blink/Commands/browse.swift @@ -34,6 +34,7 @@ import ArgumentParser import BlinkCode import Network +import ios_system struct BrowseCommand: NonStdIOCommand { static var configuration = CommandConfiguration( @@ -46,7 +47,7 @@ struct BrowseCommand: NonStdIOCommand { """ @OptionGroup var verboseOptions: VerboseOptions - var io = NonStdIO.standart + var io = NonStdIO.standard @Argument( help: "Path to connect to or http(s) vscode like editor url", @@ -77,7 +78,7 @@ public func browse_main(argc: Int32, argv: Argv) -> Int32 { setvbuf(thread_stdout, nil, _IONBF, 0) setvbuf(thread_stderr, nil, _IONBF, 0) - let io = NonStdIO.standart + let io = NonStdIO.standard io.in_ = InputStream(file: thread_stdin) io.out = OutputStream(file: thread_stdout) io.err = OutputStream(file: thread_stderr) diff --git a/Blink/Commands/code.swift b/Blink/Commands/code.swift index e50a24dfc..d62a2a0b3 100644 --- a/Blink/Commands/code.swift +++ b/Blink/Commands/code.swift @@ -34,6 +34,9 @@ import ArgumentParser import BlinkCode import Network +import ios_system + + class SharedFP { let service: CodeFileSystemService @@ -94,7 +97,7 @@ struct CodeCommand: NonStdIOCommand { """ @OptionGroup var verboseOptions: VerboseOptions - var io = NonStdIO.standart + var io = NonStdIO.standard @Argument( help: "Path to connect to or http(s) vscode like editor url", @@ -193,7 +196,7 @@ public func code_main(argc: Int32, argv: Argv) -> Int32 { setvbuf(thread_stdout, nil, _IONBF, 0) setvbuf(thread_stderr, nil, _IONBF, 0) - let io = NonStdIO.standart + let io = NonStdIO.standard io.in_ = InputStream(file: thread_stdin) io.out = OutputStream(file: thread_stdout) io.err = OutputStream(file: thread_stderr) diff --git a/Blink/Commands/facecam.swift b/Blink/Commands/facecam.swift index e56547379..6d4a1ed1e 100644 --- a/Blink/Commands/facecam.swift +++ b/Blink/Commands/facecam.swift @@ -34,6 +34,9 @@ import Foundation import ArgumentParser import AVFoundation +import ios_system + + struct FaceCam: NonStdIOCommand { static var configuration = CommandConfiguration( commandName: "facecam", @@ -43,7 +46,7 @@ struct FaceCam: NonStdIOCommand { ) @OptionGroup var verboseOptions: VerboseOptions - var io = NonStdIO.standart + var io = NonStdIO.standard struct On: NonStdIOCommand { static var configuration = CommandConfiguration( @@ -52,7 +55,7 @@ struct FaceCam: NonStdIOCommand { ) @OptionGroup var verboseOptions: VerboseOptions - var io = NonStdIO.standart + var io = NonStdIO.standard func run() throws { var sema: DispatchSemaphore? = nil @@ -117,7 +120,7 @@ struct FaceCam: NonStdIOCommand { ) @OptionGroup var verboseOptions: VerboseOptions - var io = NonStdIO.standart + var io = NonStdIO.standard func run() throws { print("See you next time!") @@ -135,7 +138,7 @@ public func facecam_main(argc: Int32, argv: Argv) -> Int32 { setvbuf(thread_stdout, nil, _IONBF, 0) setvbuf(thread_stderr, nil, _IONBF, 0) - let io = NonStdIO.standart + let io = NonStdIO.standard io.out = OutputStream(file: thread_stdout) io.err = OutputStream(file: thread_stderr) diff --git a/Blink/Commands/link_files.m b/Blink/Commands/link_files.m deleted file mode 100644 index 201cd1254..000000000 --- a/Blink/Commands/link_files.m +++ /dev/null @@ -1,144 +0,0 @@ -//////////////////////////////////////////////////////////////////////////////// -// -// B L I N K -// -// Copyright (C) 2016-2018 Blink Mobile Shell Project -// -// This file is part of Blink. -// -// Blink is free software: you can redistribute it and/or modify -// it under the terms of the GNU General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// Blink is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with Blink. If not, see . -// -// In addition, Blink is also subject to certain additional terms under -// GNU GPL version 3 section 7. -// -// You should have received a copy of these additional terms immediately -// following the terms and conditions of the GNU General Public License -// which accompanied the Blink Source Code. If not, see -// . -// -//////////////////////////////////////////////////////////////////////////////// - -#include -#import "MCPSession.h" -#import -#include "ios_system/ios_system.h" -#include "BlinkPaths.h" - -API_AVAILABLE(ios(11.0)) -@interface SyncDirectoryPicker: NSObject -- (NSArray *)pickWithInController:(UIViewController *)ctrl; -@end - -@implementation SyncDirectoryPicker { - dispatch_semaphore_t _dsema; - NSArray *_pickedPaths; -} - -- (NSArray *)pickWithInController:(UIViewController *)ctrl -{ - _dsema = dispatch_semaphore_create(0); - __block UIDocumentPickerViewController *pickerCtrl = nil; - dispatch_async(dispatch_get_main_queue(), ^{ - pickerCtrl = [[UIDocumentPickerViewController alloc] initWithDocumentTypes:@[(NSString *)kUTTypeFolder] inMode:UIDocumentPickerModeOpen]; - pickerCtrl.allowsMultipleSelection = YES; - pickerCtrl.delegate = self; - - [ctrl presentViewController:pickerCtrl animated:YES completion:nil]; - }); - - dispatch_semaphore_wait(_dsema, DISPATCH_TIME_FOREVER); - - return _pickedPaths; -} - -#pragma mark - UIDocumentPickerDelegate - -- (void)documentPickerWasCancelled:(UIDocumentPickerViewController *)controller -{ - _pickedPaths = nil; - dispatch_semaphore_signal(_dsema); -} - -- (void)documentPicker:(UIDocumentPickerViewController *)controller didPickDocumentsAtURLs:(NSArray *)urls -{ - NSMutableArray *pickedPaths = [[NSMutableArray alloc] init]; - for (NSURL *url in urls) { - if ([url startAccessingSecurityScopedResource]) { - [pickedPaths addObject:url.path]; - } - } - _pickedPaths = pickedPaths; - dispatch_semaphore_signal(_dsema); -} - -@end - -__attribute__ ((visibility("default"))) -int link_files_main(int argc, char *argv[]) { -// if (argc != 2) { -// NSString *usage = [@[ -// @"usage: link-files dest" -// ] componentsJoinedByString:@"\n"]; -// fputs(usage.UTF8String, thread_stdout); -// fputs("\n", thread_stderr); -// return 1; -// } -// NSString *args = [NSString stringWithUTF8String:argv[1]]; -// -// if (args.length == 0) { -// return 1; -// } - - NSString *arg = nil; - if (argc == 2) { - arg = [NSString stringWithUTF8String:argv[1]]; - } - - if ([arg isEqualToString:@"-h"] || [arg isEqualToString:@"--help"]) { - NSString *usage = [@[ - @"usage: link-files [dest]" - ] componentsJoinedByString:@"\n"]; - fputs(usage.UTF8String, thread_stdout); - fputs("\n", thread_stderr); - return 0; - } - - MCPSession *session = (__bridge MCPSession *)thread_context; - if (!session) { - return 1; - } - - if (@available(iOS 11.0, *)) { - SyncDirectoryPicker *picker = [[SyncDirectoryPicker alloc] init]; - NSArray *folders = [picker pickWithInController: session.device.delegate.viewController]; - - NSFileManager *fm = [[NSFileManager alloc] init]; - NSString *homePath = [BlinkPaths homePath]; - - for (NSString *path in folders) { - NSString *linkName = [arg lastPathComponent] ?: [path lastPathComponent]; - arg = nil; - NSString *linkPath = [homePath stringByAppendingPathComponent:linkName]; - NSError *error = nil; - if (![fm createSymbolicLinkAtPath:linkPath withDestinationPath:path error:&error]) { - fputs([NSString stringWithFormat:@"Can't create new symbolic link at ~/%@:\n", linkName].UTF8String, thread_stderr); - } - } - [session updateAllowedPaths]; - return 0; - } else { - fputs("link-files works only on iOS 11 or higher\n", thread_stderr); - return 1; - } -} diff --git a/Blink/Commands/mosh/MoshBootstrap.swift b/Blink/Commands/mosh/MoshBootstrap.swift new file mode 100644 index 000000000..acfe29586 --- /dev/null +++ b/Blink/Commands/mosh/MoshBootstrap.swift @@ -0,0 +1,458 @@ +////////////////////////////////////////////////////////////////////////////////// +// +// B L I N K +// +// Copyright (C) 2016-2021 Blink Mobile Shell Project +// +// This file is part of Blink. +// +// Blink is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Blink is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Blink. If not, see . +// +// In addition, Blink is also subject to certain additional terms under +// GNU GPL version 3 section 7. +// +// You should have received a copy of these additional terms immediately +// following the terms and conditions of the GNU General Public License +// which accompanied the Blink Source Code. If not, see +// . +// +//////////////////////////////////////////////////////////////////////////////// + +import ArgumentParser +import Combine +import CryptoKit + +import BlinkFiles +import SSH + +// We have decided to hard-code the version of Blink so client-server match. +// Static and override with env variable. +let MoshServerRemotePath = ".local/blink" +let MoshServerBinaryName = "mosh-server" +let MoshServerVersion = "1.4.0" +let MoshServerBlinkVersion = "17.3.0" +let MoshServerDownloadPathURL = URL(string: "https://github.com/blinksh/mosh-static-multiarch/releases/download/\(MoshServerVersion)%2Bblink-\(MoshServerBlinkVersion)/")! + +fileprivate enum Checksum { + static let DarwinArm64 = "3cdc2cf180bda497264049f43cb97557f6827795e7e612ad69ac02ea0096cbc4" + static let DarwinX86_64 = "bf42e75ab1ad3beca899da18a3f154e0e6c9c4ef5507a2e5fbc945d404ce1168" + static let LinuxAmd64 = "49e71e059e480d96b5f5b9fb15485a79c2717008fb9d9c967c85edfe2103e300" + static let LinuxArm64 = "fc8a6257f61a7d65d15206301fb010097e58521afa9ee12852e1e89ade0b8efc" + static let LinuxArmv7 = "23d440e99cfd736074b7cb12540e2b902824ac2d296a82166985d30c5f59ca13" + static func validate(data: Data, platform: Platform, architecture: Architecture) -> Bool { + let hash = SHA256.hash(data: data) + let hexHash = hash.map { byte in String(format: "%02x", byte)}.joined() + let checksum: String + switch (platform, architecture) { + case (.Darwin, .X86_64): + checksum = Self.DarwinX86_64 + case (.Darwin, .Arm64): + checksum = Self.DarwinArm64 + case (.Linux, .Amd64): + checksum = Self.LinuxAmd64 + case (.Linux, .X86_64): + checksum = Self.LinuxAmd64 + case (.Linux, .Arm64): + checksum = Self.LinuxArm64 + case (.Linux, .Armv7): + checksum = Self.LinuxArmv7 + + default: + return false + } + return checksum == hexHash + } +} + +enum Platform { + case Darwin + case Linux +} + +extension Platform { + init?(from str: String) { + switch str.lowercased() { + case "darwin": + self = .Darwin + case "linux": + self = .Linux + default: + return nil + } + } +} + +extension Platform: CustomStringConvertible { + public var description: String { + switch self { + case .Darwin: + return "darwin" + case .Linux: + return "linux" + } + } +} + +enum Architecture { + case X86_64 + case Amd64 + case Arm64 + case Armv7 +} + +extension Architecture { + init?(from str: String) { + switch str.lowercased() { + case "x86_64": + self = .X86_64 + case "aarch64": + self = .Arm64 + case "arm64": + self = .Arm64 + case "amd64": + self = .Amd64 + case "armv7": + self = .Armv7 + case "armv7l": + self = .Armv7 + default: + return nil + } + } +} + +extension Architecture { + public func downloadableDescription(for platform: Platform) throws -> String { + switch (platform, self) { + case (.Darwin, .X86_64): + return "x86_64" + case (.Darwin, .Amd64): + return "x86_64" + case (.Darwin, .Arm64): + return "arm64" + case (.Linux, .Amd64): + return "amd64" + case (.Linux, .X86_64): + return "amd64" + case (.Linux, .Arm64): + return "arm64" + case (.Linux, .Armv7): + return "armv7" + default: + throw MoshError.NoBinaryAvailable + } + } +} + +protocol MoshBootstrap { + func start(on client: SSHClient) -> AnyPublisher +} + +// NOTE We could enforce "which" on interactive shell as a different bootstrap method. +class UseMoshOnPath: MoshBootstrap { + let path: String + + init(path: String? = nil) { + self.path = path ?? MoshServerBinaryName + } + + static func staticMosh() -> UseMoshOnPath { + UseMoshOnPath(path: "~/\(MoshServerRemotePath)/\(MoshServerBinaryName)") + } + + func start(on client: SSHClient) -> AnyPublisher { + Just(self.path).setFailureType(to: Error.self).eraseToAnyPublisher() + } +} + +class InstallStaticMosh: MoshBootstrap { + let pathToStatic: String? + let promptUser: Bool + let onCancel: () -> () + let logger: MoshLogger + + init(promptUser: Bool = true, onCancel: @escaping () -> () = {}, logger: MoshLogger) { + self.pathToStatic = nil + self.promptUser = promptUser + self.onCancel = onCancel + self.logger = logger + } + + init(fromPath pathToStatic: String, onCancel: @escaping () -> () = {}, logger: MoshLogger) { + self.pathToStatic = pathToStatic + self.promptUser = false + self.onCancel = onCancel + self.logger = logger + } + + func start(on client: SSHClient) -> AnyPublisher { + let log = logger.log("InstallStaticMosh") + let prompt = InstallStaticMoshPrompt() + + let getMoshServerBinary: AnyPublisher + if let pathToStatic = self.pathToStatic { + getMoshServerBinary = Local().cloneWalkTo(pathToStatic) + + } else { + getMoshServerBinary = Just(()) + .flatMap { [unowned self] in self.platformAndArchitecture(on: client) } + .tryMap { pa in + guard let platform = pa?.0, + let architecture = pa?.1 else { + throw MoshError.NoBinaryAvailable + } + + if !self.promptUser || prompt.installMoshRequest() { + return (platform, architecture) + } else { + throw MoshError.UserCancelled + } + } + .flatMap { [unowned self] in self.getMoshServerBinary(platform: $0, architecture: $1) } + .eraseToAnyPublisher() + } + + return getMoshServerBinary + .flatMap { [unowned self] in self.installMoshServerBinary(on: client, localMoshServerBinary: $0) } + .print() + .eraseToAnyPublisher() + } + + private func platformAndArchitecture(on client: SSHClient) -> AnyPublisher<(Platform, Architecture)?, Error> { + let log = logger.log("platformAndArchitecture") + + return client.requestExec(command: "uname && uname -m") + .flatMap { s -> AnyPublisher in + s.read(max: 1024) + } + .map { String(decoding: $0 as AnyObject as! Data, as: UTF8.self).components(separatedBy: .newlines) } + .map { lines -> (Platform, Architecture)? in + log.info("uname output: \(lines)") + if lines.count != 3 { + return nil + } + + guard let platform = Platform(from: lines[0]), + let architecture = Architecture(from: lines[1]) else { + return nil + } + + return (platform, architecture) + }.eraseToAnyPublisher() + } + + func getMoshServerBinary(platform: Platform, architecture: Architecture) -> AnyPublisher { + let downloadable: String + do { + downloadable = try architecture.downloadableDescription(for: platform) + } catch { + return Fail(error: error).eraseToAnyPublisher() + } + + let moshServerReleaseName = "\(MoshServerBinaryName)-\(MoshServerVersion)+blink-\(MoshServerBlinkVersion)-\(platform)-\(downloadable)" + let localMoshServerURL = BlinkPaths.blinkURL().appending(path: moshServerReleaseName) + let moshServerDownloadURL = MoshServerDownloadPathURL.appending(path: moshServerReleaseName) + let log = logger.log("getMoshServerBinary") + let prompt = InstallStaticMoshPrompt() + + log.info("\(platform) \(architecture)") + return Local().cloneWalkTo(localMoshServerURL.path) + .catch { _ in + log.info("Downloading \(moshServerDownloadURL)") + prompt.showDownloadProgress(cancellationHandler: { [weak self] in self?.onCancel() }) + return URLSession.shared.dataTaskPublisher(for: moshServerDownloadURL) + .map(\.data) + .tryMap { data in + guard Checksum.validate(data: data, platform: platform, architecture: architecture) else { + log.error("Download mismatch. Downloaded size: \(data.count)") + throw MoshError.NoChecksumMatch + } + try data.write(to: localMoshServerURL) + prompt.progressUpdate(1.0) + return localMoshServerURL + } + .flatMap { + Local().cloneWalkTo($0.path) + } + }.eraseToAnyPublisher() + } + + private func installMoshServerBinary(on client: SSHClient, localMoshServerBinary: Translator) -> AnyPublisher { + let moshServerRemotePath = NSString(string: MoshServerRemotePath) + let moshServerBinaryPath = moshServerRemotePath.appendingPathComponent(MoshServerBinaryName) + let log = logger.log("installMoshServerBinary") + let prompt = InstallStaticMoshPrompt() + + log.info("on \(moshServerBinaryPath)") + var uploaded: UInt64 = 0 + return client.requestSFTP() + .tryMap { try SFTPTranslator(on: $0) } + .flatMap { sftp in + prompt.showUploadProgress(cancellationHandler: { [weak self] in self?.onCancel() }) + return sftp.cloneWalkTo(moshServerRemotePath.standardizingPath) + .catch { _ in + log.info("Path not found: \(moshServerRemotePath.standardizingPath). Creating it...") + return sftp.mkPath(path: moshServerRemotePath.standardizingPath) + } + // Upload file + .flatMap { dest in + dest.copy(from: [localMoshServerBinary]) + .tryMap { info in + uploaded += info.written + let percentage = Float(uploaded) / Float(info.size) + // Tested. If something happens, it closes properly. + // if percentage > 0.5 { throw MoshError.UserCancelled } + prompt.progressUpdate(percentage) + } + } + .last() + .flatMap { _ -> AnyPublisher in + let uploadedBinaryPath = moshServerRemotePath + .appendingPathComponent((localMoshServerBinary.current as NSString).lastPathComponent) + log.info("File uploaded at \(uploadedBinaryPath). Moving to \(moshServerBinaryPath)") + + + return sftp.cloneWalkTo(moshServerBinaryPath) + .flatMap { t in + t.remove().flatMap { _ in sftp.cloneWalkTo(uploadedBinaryPath) } + } + .catch { _ in sftp.cloneWalkTo(uploadedBinaryPath) } + .flatMap { $0.wstat([.name: MoshServerBinaryName]) } + .flatMap { _ in sftp.cloneWalkTo(moshServerBinaryPath) } + .eraseToAnyPublisher() + } + } + .map { $0.current } + // Set execution flag. + .flatMap { moshPath in + let command = "chmod +x \(moshPath)" + log.info("chmod +x \(moshPath)") + return client.requestExec(command: command) + .flatMap { $0.read_err(max: 1024) } + .tryMap { err_out in + prompt.progressUpdate(1.0) + if err_out.count > 0 { + log.error("chmod err: \(err_out)") + throw MoshError.NoBinaryExecFlag + } else { return moshPath } + } + } + .eraseToAnyPublisher() + } + + deinit { + print("Install Mosh OUT") + } +} + +class InstallStaticMoshPrompt { + public var name: String { "User Prompt" } + var window: UIWindow? = nil + var progressView: UIProgressView? = nil + + public func installMoshRequest() -> Bool { + var shouldInstall = false + let semaphore = DispatchSemaphore(value: 0) + + let alert = UIAlertController(title: "Mosh server not found", message: "Blink will try to install mosh on the remote.", preferredStyle: .alert) + + alert.addAction( + UIAlertAction(title: NSLocalizedString("Continue", comment: "Install"), + style: .default, + handler: { _ in + shouldInstall = true + semaphore.signal() + self.window = nil + })) + alert.addAction( + UIAlertAction(title: NSLocalizedString("Cancel", comment: "Do not install"), + style: .cancel, + handler: { _ in + shouldInstall = false + semaphore.signal() + self.window = nil + })) + + self.displayAlert(alert, completion: nil) + + semaphore.wait() + + return shouldInstall + } + + public func showDownloadProgress(cancellationHandler: @escaping () -> ()) { + // Show download progress. Communicate progress. Once done, dismiss. + let alert = UIAlertController(title: "Downloading mosh-server to device", message: "", preferredStyle: .alert) + + alert.addAction( + UIAlertAction(title: NSLocalizedString("Cancel", comment: "Cancel the download"), + style: .cancel, + handler: { [weak self] _ in + cancellationHandler() + self?.window = nil + })) + + self.displayAlert(alert, completion: nil) + } + + public func showUploadProgress(cancellationHandler: @escaping () -> ()) { + let alert = UIAlertController(title: "Uploading mosh-server to remote", message: "", preferredStyle: .alert) + + alert.addAction( + UIAlertAction(title: NSLocalizedString("Cancel", comment: "Cancel the upload"), + style: .cancel, + handler: { [weak self] _ in + cancellationHandler() + self?.window = nil + self?.progressView = nil + })) + + self.displayAlert(alert, completion: { + let margin:CGFloat = 8.0 + let rect = CGRect(x: margin, y: 72.0, width: alert.view.frame.width - margin * 2.0 , height: 2.0) + self.progressView = UIProgressView(frame: rect) + self.progressView!.tintColor = self.window?.tintColor + alert.view.addSubview(self.progressView!) + }) + } + + // Kinda like the DownloadDelegate for the URLSession. But we don't need to reuse this. + public func progressUpdate(_ progress: Float) { + if progress == 1.0 { + self.progressView = nil + self.window = nil + } else { + DispatchQueue.main.async { + self.progressView?.progress = progress + } + } + } + + private func displayAlert(_ alert: UIAlertController, completion: (() -> ())?) { + DispatchQueue.main.async { + let foregroundActiveScene = UIApplication.shared.connectedScenes.filter { $0.activationState == .foregroundActive }.first + guard let foregroundWindowScene = foregroundActiveScene as? UIWindowScene else { + // semaphore.signal() + return + } + + let window = UIWindow(windowScene: foregroundWindowScene) + self.window = window + window.rootViewController = UIViewController() + window.windowLevel = .alert + 1 + window.makeKeyAndVisible() + window.rootViewController!.present(alert, animated: true, completion: completion) + } + } +} diff --git a/Blink/Commands/mosh/MoshClientParams.swift b/Blink/Commands/mosh/MoshClientParams.swift new file mode 100644 index 000000000..248d2851d --- /dev/null +++ b/Blink/Commands/mosh/MoshClientParams.swift @@ -0,0 +1,56 @@ +////////////////////////////////////////////////////////////////////////////////// +// +// B L I N K +// +// Copyright (C) 2016-2023 Blink Mobile Shell Project +// +// This file is part of Blink. +// +// Blink is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Blink is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Blink. If not, see . +// +// In addition, Blink is also subject to certain additional terms under +// GNU GPL version 3 section 7. +// +// You should have received a copy of these additional terms immediately +// following the terms and conditions of the GNU General Public License +// which accompanied the Blink Source Code. If not, see +// . +// +//////////////////////////////////////////////////////////////////////////////// + + +import Foundation + +struct MoshClientParams { + let predictionMode: BKMoshPrediction + let predictOverwrite: String? + let experimentalRemoteIP: BKMoshExperimentalIP + let customUDPPort: String? + let server: String + let remoteExecCommand: String? + + init(extending cmd: MoshCommand) { + let bkHost = BKHosts.withHost(cmd.hostAlias) + + let customUDPPort: String? = if let moshPort = bkHost?.moshPort { String(describing: moshPort) } else { nil } + self.customUDPPort = cmd.customUDPPort ?? customUDPPort + let moshServer: String? = if let moshServer = bkHost?.moshServer, !moshServer.isEmpty { moshServer } else { nil } + self.server = cmd.server ?? moshServer ?? "mosh-server" + self.predictionMode = cmd.predict ?? BKMoshPrediction(UInt32(truncating: bkHost?.prediction ?? 0)) + self.predictOverwrite = cmd.predictOverwrite ? "yes" : bkHost?.moshPredictOverwrite + self.experimentalRemoteIP = cmd.experimentalRemoteIP ?? BKMoshExperimentalIP(UInt32(truncating: bkHost?.moshExperimentalIP ?? 0)) + let remoteExecCommand: String? = if let command = bkHost?.moshStartup, !command.isEmpty { command } else { nil } + self.remoteExecCommand = !cmd.remoteExecCommand.isEmpty ? cmd.remoteExecCommand.joined(separator: " ") : remoteExecCommand + } +} diff --git a/Blink/Commands/mosh/MoshCommand.swift b/Blink/Commands/mosh/MoshCommand.swift new file mode 100644 index 000000000..45fb80ab5 --- /dev/null +++ b/Blink/Commands/mosh/MoshCommand.swift @@ -0,0 +1,250 @@ +////////////////////////////////////////////////////////////////////////////////// +// +// B L I N K +// +// Copyright (C) 2016-2019 Blink Mobile Shell Project +// +// This file is part of Blink. +// +// Blink is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Blink is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Blink. If not, see . +// +// In addition, Blink is also subject to certain additional terms under +// GNU GPL version 3 section 7. +// +// You should have received a copy of these additional terms immediately +// following the terms and conditions of the GNU General Public License +// which accompanied the Blink Source Code. If not, see +// . +// +//////////////////////////////////////////////////////////////////////////////// + +import Foundation +import ArgumentParser + +fileprivate let Version = "1.4.0" + +struct MoshCommand: ParsableCommand { + static var configuration = CommandConfiguration( + commandName: "mosh", + abstract: "", + discussion: """ + """, + version: Version) + + @Flag(help: "Use Blink's static mosh-server bin on remote .local/blink.") + var installStatic: Bool = false + + @Option( + name: [.customLong("install-static-from")], + help: "Install custom static mosh-server from path." + ) + var installStaticFromPath: String? + + @Option(name: .shortAndLong, + help: "Path to remote mosh-server binary.") + var server: String? + + @Option( + name: [.customShort("r")], + help: "Prediction mode", + transform: { try BKMoshPrediction(parsing: $0) }) + var predict: BKMoshPrediction? + + @Flag ( + name: [.customShort("o")] + ) + var predictOverwrite: Bool = false + + @Flag var verbose: Bool = false + + @Flag ( + name: [.customShort("T")], + help: "Do not start a TTY" + ) + var noSshPty: Bool = false + + @Option( + name: [.customShort("R")], + help: "How to discover the IP address that the mosh-client connects to: default, remote or local", + transform: { try BKMoshExperimentalIP(parsing: $0) } + ) + var experimentalRemoteIP: BKMoshExperimentalIP? + + @Flag(exclusivity: .exclusive) + var addressFamily: AddressFamily? + + // Mosh Key + @Option( + name: [.customShort("k")], + help: "Use the provided server-side key for mosh connection." + ) + var customKey: String? + + // UDP Port + @Option( + name: [.customShort("p")], + help: "Use a particular server-side UDP port or port range, for example, if this is the only port that is forwarded through a firewall to the server. Otherwise, mosh will choose a port between 60000 and 61000." + ) + var customUDPPort: String? + + // SSH Port + @Option( + name: [.customShort("P")], + help: "Specifies the SSH port to initialize mosh-server on remote host." + ) + var customSSHPort: UInt16? + + // Identity + @Option( + name: [.customShort("I")], + help: .init( + """ + Selects a file from which the identity (private key) for public key authentication is read. The default is ~/.ssh/id_dsa, ~/.ssh/id_ecdsa, ~/.ssh/id_ed25519 and ~/.ssh/id_rsa. Identity files may also be specified on a per-host basis in the configuration pane in the Settings of Blink. + """, + valueName: "identity" + ) + ) + var identityFile: String? + + // Connect to User at Host + @Argument(help: "[user@]host[#port]", + transform: { UserAtHostAndPort($0) }) + var userAtHostAndPort: UserAtHostAndPort + var hostAlias: String { userAtHostAndPort.hostAlias } + var user: String? { userAtHostAndPort.user } + var sshPort: UInt16? { + get { if let port = customSSHPort { port } else { userAtHostAndPort.port } } + } + + @Argument( + parsing: .unconditionalRemaining, + help: .init( + "If a is specified, it is executed on the remote host instead of a login shell", + valueName: "remoteCommand" + ) + ) + + fileprivate var cmd: [String] = [] + var remoteExecCommand: [String] { + get { + if cmd.first == "--" { + return Array(cmd.dropFirst()) + } else { + return cmd + } + } + } + + func validate() throws { + if addressFamily != nil && experimentalRemoteIP != BKMoshExperimentalIPLocal { + throw ValidationError("Address Family can only be used with 'local' IP resolution (-R).") + } + } +} + +extension MoshCommand { + func bkSSHHost() throws -> BKSSHHost { + var params: [String:Any] = [:] + + if let user = self.user { + params["user"] = user + } + + if let port = self.sshPort { + params["port"] = String(port) + } + + if let identityFile = self.identityFile { + params["identityfile"] = identityFile + } + + if self.verbose { + params["loglevel"] = "INFO" + } + // params["loglevel"] = "DEBUG" + + params["compression"] = "no" + return try BKSSHHost(content: params) + } +} + +extension BKMoshPrediction: CustomStringConvertible { + init(parsing: String) throws { + switch parsing.lowercased() { + case "adaptive": + self = BKMoshPredictionAdaptive + case "always": + self = BKMoshPredictionAlways + case "never": + self = BKMoshPredictionNever + case "experimental": + self = BKMoshPredictionExperimental + default: + throw ValidationError("Unknown prediction mode, must be: adaptive, always, never, experimental.") + } + } + + public var description: String { + switch self { + case BKMoshPredictionAdaptive: + "adaptive" + case BKMoshPredictionAlways: + "always" + case BKMoshPredictionNever: + "never" + case BKMoshPredictionExperimental: + "experimental" + default: + "unknown" + } + } +} + +extension BKMoshExperimentalIP { + init(parsing: String) throws { + switch parsing.lowercased() { + case "default": + self = BKMoshExperimentalIPNone + case "local": + self = BKMoshExperimentalIPLocal + case "remote": + self = BKMoshExperimentalIPRemote + default: + throw ValidationError("Unknown experimental-ip mode, must be: default, local or remote.") + } + } +} + +enum AddressFamily: String, EnumerableFlag { + case IPv4 + case IPv6 + + static func name(for value: AddressFamily) -> NameSpecification { + switch value { + case .IPv4: + return NameSpecification([.customShort(Character("4")), .customLong("inet4")]) + case .IPv6: + return NameSpecification([.customShort(Character("6")), .customLong("inet6")]) + } + } + + static func help(for value: AddressFamily) -> ArgumentHelp? { + switch value { + case .IPv4: + return "Use IPv4 only on 'local' IP resolution" + case .IPv6: + return "Use IPv6 only on 'local' IP resolution" + } + } +} diff --git a/Blink/Commands/mosh/MoshServerParams.swift b/Blink/Commands/mosh/MoshServerParams.swift new file mode 100644 index 000000000..2f1df5d4e --- /dev/null +++ b/Blink/Commands/mosh/MoshServerParams.swift @@ -0,0 +1,104 @@ +////////////////////////////////////////////////////////////////////////////////// +// +// B L I N K +// +// Copyright (C) 2016-2023 Blink Mobile Shell Project +// +// This file is part of Blink. +// +// Blink is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Blink is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Blink. If not, see . +// +// In addition, Blink is also subject to certain additional terms under +// GNU GPL version 3 section 7. +// +// You should have received a copy of these additional terms immediately +// following the terms and conditions of the GNU General Public License +// which accompanied the Blink Source Code. If not, see +// . +// +//////////////////////////////////////////////////////////////////////////////// + + +import Foundation + + +struct MoshServerParams { + let key: String + let udpPort: String + let remoteIP: String + let versionString: String? +} + +extension MoshServerParams { + init(parsing output: String, remoteIP: String?) throws { + if let remoteIP = remoteIP { + self.remoteIP = remoteIP + } else { + let remoteIPPattern = try! NSRegularExpression( + pattern: "(?m)^MOSH SSH_CONNECTION (\\S*) (\\d*) (\\S*) (\\d*)$", + options: [] + ) + if let remoteIPMatch = remoteIPPattern.firstMatch( + in: output, + options: [], + range: NSRange(location: 0, length: output.utf8.count) + ) { + self.remoteIP = String(output[Range(remoteIPMatch.range(at: 3), in: output)!]) + } else { + throw MoshError.NoRemoteServerIP + } + } + + let connectPattern = try! NSRegularExpression( + pattern: "(?m)^MOSH CONNECT (\\d+) (\\S*)$", + options: [] + ) + if let connectMatch = connectPattern.firstMatch( + in: output, + options: [], + range: NSRange(output.startIndex..., in: output) + ) { + self.udpPort = String(output[Range(connectMatch.range(at: 1), in: output)!]) + self.key = String(output[Range(connectMatch.range(at: 2), in: output)!]) + } else { + throw MoshError.NoMoshServerArgs + } + + let versionStringPattern = try! NSRegularExpression( + pattern: "\\+blink-(\\d+\\.\\d+\\.\\d+)", + options: [] + ) + if let versionStringMatch = versionStringPattern.firstMatch( + in: output, + options: [], + range: NSRange(output.startIndex..., in: output) + ) { + self.versionString = String(output[Range(versionStringMatch.range(at: 1), in: output)!]) + } else { + self.versionString = nil + } + } + + func isRunningOlderStaticVersion() -> Bool { + guard let versionString = self.versionString else { + return false + } + + if MoshServerBlinkVersion.compare(versionString, options: .numeric) == .orderedDescending { + return true + } else { + return false + } + } +} diff --git a/Blink/Commands/mosh/mosh.swift b/Blink/Commands/mosh/mosh.swift new file mode 100644 index 000000000..00d196215 --- /dev/null +++ b/Blink/Commands/mosh/mosh.swift @@ -0,0 +1,593 @@ +////////////////////////////////////////////////////////////////////////////////// +// +// B L I N K +// +// Copyright (C) 2016-2019 Blink Mobile Shell Project +// +// This file is part of Blink. +// +// Blink is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Blink is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Blink. If not, see . +// +// In addition, Blink is also subject to certain additional terms under +// GNU GPL version 3 section 7. +// +// You should have received a copy of these additional terms immediately +// following the terms and conditions of the GNU General Public License +// which accompanied the Blink Source Code. If not, see +// . +// +//////////////////////////////////////////////////////////////////////////////// + +import Combine +import Dispatch + +import SSH +import ios_system + +enum MoshError: Error, LocalizedError { + case NoBinaryAvailable + case NoBinaryExecFlag + case NoChecksumMatch + case UserCancelled + case NoMoshServerArgs + case NoRemoteServerIP + case AddressInfo(String) + case MissingArguments(String) + + public var errorDescription: String? { + switch self { + case .NoBinaryAvailable: + return "Could not find static binary for the remote platform and architecture." + case .NoBinaryExecFlag: + return "Could not set execution flag for static mosh-server binary." + case .NoChecksumMatch: + return "Error downloading binary. The checksums do not match." + case .UserCancelled: + return "User cancelled the operation" + case .NoMoshServerArgs: + return "Did not find mosh server startup message. (Have you installed mosh on your server?)" + case .NoRemoteServerIP: + return "Bad Mosh SSH_CONNECTION String." + case .AddressInfo(let error): + return "Address resolution failed - \(error)" + case .MissingArguments(let message): + return "\(message)" + } + } +} + +@objc public class BlinkMosh: Session { + var exitCode: Int32 = 0 + var sshCancellable: AnyCancellable? = nil + var proxyCancellable: AnyCancellable? = nil + var proxyStream: SSH.Stream? = nil + var currentRunLoop: RunLoop! + var stdin: InputStream! + var stdout: OutputStream! + var stderr: OutputStream! + var isVerbose: Bool = false + private var initialMoshParams: MoshParams? = nil + private let mcpSession: MCPSession + private var suspendSemaphore: DispatchSemaphore? = nil + private let escapeKey: String + private var logger: MoshLogger! = nil + var isRunloopRunning = false + + let stateCallback: mosh_state_callback = { (context, buffer, size) in + guard let buffer = buffer, let context = context else { + //print("Mosh returned with no encoded state.") + return + } + let data = Data(bytes: buffer, count: size) + let session = Unmanaged.fromOpaque(context).takeUnretainedValue() + session.onStateEncoded(data) + } + + @objc init!(mcpSession: MCPSession, device: TermDevice!, andParams params: SessionParams!) { + if let escapeKey = ProcessInfo.processInfo.environment["MOSH_ESCAPE_KEY"], + escapeKey.count == 1 { + self.escapeKey = escapeKey + } else { + self.escapeKey = "\u{1e}" + } + self.mcpSession = mcpSession + + super.init(device: device, andParams: params) + + self.stdin = InputStream(file: stream.in) + self.stdout = OutputStream(file: stream.out) + self.stderr = OutputStream(file: stream.err) + } + + @objc public override func main(_ argc: Int32, argv: Argv) -> Int32 { + //print("mosh main") + mcpSession.setActiveSession() + self.currentRunLoop = RunLoop.current + // In ObjC, sessionParams is a covariable for MoshParams. + // In Swift we need to cast. + if let initialMoshParams = self.sessionParams as? MoshParams, + let _ = initialMoshParams.encodedState { + //print("Init mosh from Params") + return moshMain(initialMoshParams) + } else { + let command: MoshCommand + do { + command = try MoshCommand.parse(Array(argv.args(count: argc)[1...])) + } catch { + let message = MoshCommand.message(for: error) + return die(message: message) + } + self.isVerbose = command.verbose + self.logger = MoshLogger(output: self.stderr, logLevel: command.verbose ? .info : .error) + + let moshParams: MoshParams + do { + moshParams = try startMoshServer(using: command) + self.copyToSession(moshParams: moshParams) + } catch { + return die(message: "\(error) - \(error.localizedDescription)") + } + + return moshMain(moshParams) + } + } + + func startMoshServer(using command: MoshCommand) throws -> MoshParams { + let host: BKSSHHost + let config: SSHClientConfig + let hostName: String + let log = logger.log("startMoshServer") + + host = try BKConfig().bkSSHHost(command.hostAlias, extending: command.bkSSHHost()) + hostName = host.hostName ?? command.hostAlias + config = try SSHClientConfigProvider.config(host: host, using: device) + + let moshClientParams = MoshClientParams(extending: command) + let moshServerParams: MoshServerParams + if let customKey = command.customKey { + guard let customUDPPort = moshClientParams.customUDPPort else { + throw MoshError.MissingArguments("If MOSH_KEY is set, port is required. (-p)") + } + + // Resolved as part of the host info or explicit on params. + let remoteIP = hostName + moshServerParams = MoshServerParams(key: customKey, udpPort: customUDPPort, remoteIP: remoteIP, versionString: nil) + log.info("Manual Mosh server bootstrapped with params \(moshServerParams)") + } else { + let moshServerStartupArgs = getMoshServerStartupArgs(udpPort: moshClientParams.customUDPPort, + colors: nil, + exec: moshClientParams.remoteExecCommand) + + let sequence: [MoshBootstrap] + // NOTE This is an extra non-standard parameter, so don't want to change the typical mosh flow. Some may + // install it by mistake and in some cases, this could be a security concern. + if command.installStatic { + sequence = [//UseMoshOnPath.staticMosh(), + InstallStaticMosh(onCancel: { [weak self] in self?.kill() }, logger: self.logger)] + } else if let staticPath = command.installStaticFromPath { + sequence = [InstallStaticMosh(fromPath: staticPath, onCancel: { [weak self] in self?.kill() }, logger: self.logger)] + } else if moshClientParams.server != "mosh-server" { + sequence = [UseMoshOnPath(path: moshClientParams.server)] + } else { + sequence = [UseMoshOnPath.staticMosh(), UseMoshOnPath(path: moshClientParams.server)] + } + + let pty: SSH.SSHClient.PTY? + if command.noSshPty { + pty = nil + } else { + pty = SSH.SSHClient.PTY(rows: Int32(self.device.rows), columns: Int32(self.device.cols)) + } + + var sshError: Error? = nil + var _moshServerParams: MoshServerParams? = nil + self.sshCancellable = SSHClient.dial(hostName, with: config, withProxy: { [weak self] in + guard let self = self + else { + return + } + self.mcpSession.setActiveSession() + self.executeProxyCommand(command: $0, sockIn: $1, sockOut: $2) + }) + .flatMap { self.bootstrapMoshServer(on: $0, + sequence: sequence, + experimentalRemoteIP: moshClientParams.experimentalRemoteIP, + family: command.addressFamily, + args: moshServerStartupArgs, + withPTY: pty) } + //.print() + .sink( + receiveCompletion: { completion in + switch completion { + case .failure(let error): + sshError = error + default: + break + } + self.kill() + }, + receiveValue: { params in + _moshServerParams = params + }) + + self.isRunloopRunning = true + SSHClient.run() + self.isRunloopRunning = false + + if let error = sshError { + throw error + } + + guard let _moshServerParams = _moshServerParams else { + throw MoshError.NoMoshServerArgs + } + moshServerParams = _moshServerParams + log.info("Remote Mosh server bootstrapped with params \(moshServerParams)") + + if moshServerParams.isRunningOlderStaticVersion() { + print("New Blink mosh-server available. Use --install-static to update.", to: &self.stderr) + } + } + + return MoshParams(server: moshServerParams, client: moshClientParams) + } + + private func moshMain(_ moshParams: MoshParams) -> Int32 { + //print("moshMain active") + + let originalRawMode = device.rawMode + self.device.rawMode = true + + defer { + device.rawMode = originalRawMode + } + + let _selfRef = UnsafeMutableRawPointer(Unmanaged.passUnretained(self).toOpaque()) + let encodedState = [UInt8](moshParams.encodedState ?? Data()) + + if let localesPath = Bundle.main.path(forResource: "locales", ofType: "bundle"), + let ccharLocalesPath = localesPath.cString(using: .utf8) { + setenv("PATH_LOCALE", ccharLocalesPath, 1) + } + + self.sessionParams.cleanEncodedState() + + mosh_main( + self.stdin.file, + self.stdout.file, + self.device.window(), + self.stateCallback, + _selfRef, + moshParams.ip, + moshParams.port, + moshParams.key, + moshParams.predictionMode, + encodedState, + encodedState.count, + moshParams.predictOverwrite + ) + + return 0 + } + + private func getMoshServerStartupArgs(udpPort: String?, + colors: String?, + exec: String?) -> String { + var args = ["new", "-s", "-c", colors ?? "256"] + if let lang = getenv("LANG") { + let localeFallback = "LANG=\(String(cString: lang))" + args.append(contentsOf: ["-l", localeFallback]) + } + + if let udpPort = udpPort { + args.append(contentsOf: ["-p", udpPort]) + } + if let exec = exec { + args.append(contentsOf: ["--", exec]) + } + + return args.joined(separator: " ") + } + + private func bootstrapMoshServer(on client: SSHClient, + sequence: [MoshBootstrap], + experimentalRemoteIP: BKMoshExperimentalIP, + family: AddressFamily?, + args: String, + withPTY pty: SSH.SSHClient.PTY? = nil) -> AnyPublisher { + let log = logger.log("bootstrapMoshServer") + log.info("Trying bootstrap with sequence: \(sequence), experimental: \(experimentalRemoteIP), family: \(family), args: \(args)") + + if sequence.isEmpty { + return Fail(error: MoshError.NoBinaryAvailable).eraseToAnyPublisher() + } + + func tryBootstrap(_ sequence: [MoshBootstrap]) -> AnyPublisher { + if sequence.count == 0 { + return .fail(error: MoshError.NoMoshServerArgs) + } + + let bootstrap = sequence.first! + log.info("Trying \(bootstrap)") + return Just(bootstrap) + .flatMap { $0.start(on: client) } + .map { moshServerPath -> String in + if experimentalRemoteIP == BKMoshExperimentalIPRemote { + return "echo \"MOSH SSH_CONNECTION $SSH_CONNECTION\" && \(moshServerPath) \(args)" + } else { + return "\(moshServerPath) \(args)" + } + } + .flatMap { + log.info("Connecting to \($0)") + return client.requestExec(command: $0, withPTY: pty) + } + .flatMap { s -> AnyPublisher in + // The SSH PTY will multiplex, so we only try to parse stdout in all cases. + s.read(max: 1024).eraseToAnyPublisher() //.zip(s.read_err(max: 1024)).eraseToAnyPublisher() + } + .flatMap { data -> AnyPublisher in + return Just(data) + .map { + String(decoding: $0 as AnyObject as! Data, as: UTF8.self) + } + .tryMap { output -> MoshServerParams in + log.info("Command output: \(output)") + // IP Resolution + switch experimentalRemoteIP { + case BKMoshExperimentalIPRemote: + // remote - echo SSH_CONNECTION on remote for parsing. + return try MoshServerParams(parsing: output, remoteIP: nil) + case BKMoshExperimentalIPLocal: + // local - resolve address on its own. + let remoteIP = try self.resolveAddress(host: client.host, port: client.options.port, family: family) + return try MoshServerParams(parsing: output, remoteIP: remoteIP) + default: + // default - get it from the established SSH Connection. + return try MoshServerParams(parsing: output, remoteIP: client.clientAddressIP()) + } + } + .catch{ err in + //let err = String(decoding: err as AnyObject as! Data, as: UTF8.self) + log.warn("Bootstrap failed with \(err)") + var sequence = sequence + sequence.removeFirst() + return tryBootstrap(sequence) + } + .eraseToAnyPublisher() + } + .print() + .eraseToAnyPublisher() + } + + return tryBootstrap(sequence) + } + + private func copyToSession(moshParams: MoshParams) { + if let sessionParams = self.sessionParams as? MoshParams { + sessionParams.copy(from: moshParams) + } + } + + // Migrated from Objc, based on... + // getaddrinfo + // https://stackoverflow.com/questions/39857435/swift-getaddrinfo + // getnameinfo + // https://stackoverflow.com/questions/44478074/swift-getnameinfo-unreliable-results-for-ipv6 + private func resolveAddress(host: String, port: String?, family: AddressFamily?) throws -> String { + guard let port = (port ?? "22").cString(using: .utf8) else { + throw MoshError.AddressInfo("Invalid port") + } + + let ai_family = { + switch family { + case .IPv4: + AF_INET + case .IPv6: + AF_INET6 + default: + AF_UNSPEC + } + }() + + var hints = addrinfo( + ai_flags: 0, + ai_family: ai_family, + ai_socktype: SOCK_STREAM, + ai_protocol: IPPROTO_TCP, + ai_addrlen: 0, + ai_canonname: nil, + ai_addr: nil, + ai_next: nil) + var result: UnsafeMutablePointer? = nil + let err = getaddrinfo(host, port, &hints, &result) + if err != 0 { + throw MoshError.AddressInfo("getaddrinfo failed with \(err)") + } + defer { freeaddrinfo(result) } + + guard let firstAddr = result?.pointee else { + throw MoshError.AddressInfo("No address info found") + } + for ai in sequence(first: firstAddr, next: { $0.ai_next?.pointee }) { + if (ai.ai_family != AF_INET && ai.ai_family != AF_INET6) { + continue; + } + + var buffer = [CChar](repeating: 0, count: Int(NI_MAXHOST)) + var port = port + if getnameinfo(ai.ai_addr, ai.ai_addrlen, + &buffer, socklen_t(buffer.count), + &port, socklen_t(port.count), + NI_NUMERICHOST | NI_NUMERICSERV) != 0 { + print("getnameinfo failed") + continue + } + + return String(cString: buffer) + } + + throw MoshError.AddressInfo("Could not resolve address through getnameinfo.") + } + + private func executeProxyCommand(command: String, sockIn: Int32, sockOut: Int32) { + print("Running ProxyCommand") + + let hostName: String + let config: SSHClientConfig + let stdioHostAndPort: BindAddressInfo + let proxyCommand: SSHCommand + do { + var argv = command.components(separatedBy: " ") + if self.isVerbose { + argv.append("-vv") + } + proxyCommand = try SSHCommand.parse(Array(argv[1...])) + stdioHostAndPort = proxyCommand.stdioHostAndPort! + let commandHost = try proxyCommand.bkSSHHost() + let host = try BKConfig().bkSSHHost(proxyCommand.hostAlias, extending: commandHost) + hostName = host.hostName ?? proxyCommand.hostAlias + config = try SSHClientConfigProvider.config(host: host, using: device) + } catch { + print("Configuration error - \(error)", to: &stderr) + shutdown(sockIn, SHUT_RDWR) + shutdown(sockOut, SHUT_RDWR) + return + } + + let outStream = DispatchOutputStream(stream: sockOut) + let inStream = DispatchInputStream(stream: sockIn) + + Thread { + self.proxyCancellable = SSHClient.dial(hostName, with: config) + .flatMap() { $0.requestForward(to: stdioHostAndPort.bindAddress, port: Int32(stdioHostAndPort.port), from: "stdio", localPort: 22) + } + .sink( + receiveCompletion: { completion in + if case .failure(let error) = completion { + print("Proxy forward error - \(error)", to: &self.stderr) + self.proxyCancellable = nil + shutdown(sockIn, SHUT_RDWR) + shutdown(sockOut, SHUT_RDWR) + } + }, + receiveValue: { s in + self.proxyStream = s + s.connect(stdout: outStream, stdin: inStream) + }) + + SSHClient.run() + print("Mosh proxy thread out") + }.start() + + } + + @objc public override func kill() { + if isRunloopRunning { + proxyStream?.cancel() + proxyStream = nil + proxyCancellable = nil + sshCancellable = nil + } else { + // MOSH-ESC . + self.device.write(String("\(self.escapeKey)\u{2e}")) + pthread_kill(self.tid, SIGINT) + } + } + + @objc public override func suspend() { + if sshCancellable == nil { + suspendSemaphore = DispatchSemaphore(value: 0) + // MOSH-ESC C-z + self.device.write(String("\(self.escapeKey)\u{1a}")) + print("Session suspend called") + let _ = suspendSemaphore!.wait(timeout: (DispatchTime.now() + 2.0)) + print("Session suspended") + } + } + + @objc public override func sigwinch() { + if let tid = self.tid { + pthread_kill(tid, SIGWINCH); + } + } + + @objc public override func handleControl(_ control: String!) { + if isRunloopRunning { + self.kill() + } + } + + func onStateEncoded(_ encodedState: Data) { + self.sessionParams.encodedState = encodedState + print("Encoding session") + if let sema = suspendSemaphore { + sema.signal() + } + } + + func die(message: String) -> Int32 { + print(message, to: &stderr) + print("Use mosh1 for the deprecated (previous) mosh version.", to: &stderr) + return -1 + } + + deinit { + print("Mosh is out") + } +} + +extension MoshParams { + convenience init(server: MoshServerParams, client: MoshClientParams) { + self.init() + + self.key = server.key + self.port = server.udpPort + self.ip = server.remoteIP + self.predictionMode = String(describing: client.predictionMode) + self.predictOverwrite = client.predictOverwrite + self.serverPath = client.server + } +} + +struct MoshLogger { + var handler = [BlinkLogging.LogHandlerFactory]() + init(output: OutputStream, logLevel: BlinkLogLevel = .error) { + handler.append( + { + $0 + .filter(logLevel: logLevel) + .format { [ ($0[.component] as? String)?.appending(":") ?? "global:", + $0[.message] as? String ?? "" + ].joined(separator: " ") } + // .sink(receiveValue: { print($0[.message]) }) + .sinkToStream(output) + } + ) + } + + func log(_ component: String) -> BlinkLogger { + BlinkLogger(component, handlers: handler) + } +} + +extension Publisher { + fileprivate func sinkToStream(_ stream: OutputStream) -> AnyCancellable where Self.Output == [BlinkLogKeys:Any] { + let out = NonStdIO(err: stream) + return sink(receiveCompletion: { _ in }, + receiveValue: { + out.printError($0[.message] ?? "") + }) + } +} diff --git a/Blink/Commands/skstore.swift b/Blink/Commands/skstore.swift index 70c20b755..1326fe34a 100644 --- a/Blink/Commands/skstore.swift +++ b/Blink/Commands/skstore.swift @@ -35,6 +35,7 @@ import StoreKit import ArgumentParser import BlinkConfig +import ios_system struct SKStoreCmd: NonStdIOCommand { static var configuration = CommandConfiguration( @@ -44,7 +45,7 @@ struct SKStoreCmd: NonStdIOCommand { ) @OptionGroup var verboseOptions: VerboseOptions - var io = NonStdIO.standart + var io = NonStdIO.standard @Argument( help: "attribute" @@ -90,7 +91,7 @@ public func skstore_main(argc: Int32, argv: Argv) -> Int32 { setvbuf(thread_stdout, nil, _IONBF, 0) setvbuf(thread_stderr, nil, _IONBF, 0) - let io = NonStdIO.standart + let io = NonStdIO.standard io.out = OutputStream(file: thread_stdout) io.err = OutputStream(file: thread_stderr) diff --git a/Blink/Commands/ssh/CopyFiles.swift b/Blink/Commands/ssh/CopyFiles.swift index 942011216..7097b098d 100644 --- a/Blink/Commands/ssh/CopyFiles.swift +++ b/Blink/Commands/ssh/CopyFiles.swift @@ -37,6 +37,7 @@ import Foundation import ArgumentParser import BlinkFiles import SSH +import ios_system fileprivate let Version = "1.0.1" @@ -55,6 +56,7 @@ public func copyfiles_main(argc: Int32, argv: Argv) -> Int32 { struct BlinkCopyCommand: ParsableCommand { static var configuration = CommandConfiguration( + commandName: "fcp", // Optional abstracts and discussions are used for help output. abstract: "Copy SOURCE to DEST or multiple SOURCEs to a DEST directory.", discussion: """ @@ -73,13 +75,25 @@ struct BlinkCopyCommand: ParsableCommand { help: "Copy only when source is newer than destination, considering the timestamp. This includes -p.") var update: Bool = false - @Argument(help: "SOURCE(s)", - transform: { try FileLocationPath($0) }) - var source: FileLocationPath - - @Argument(help: "DEST", - transform: { try FileLocationPath($0) }) - var destination: FileLocationPath = try! FileLocationPath(".") + @Argument(help: "SOURCE(s) ... DEST", + transform: { + try FileLocationPath($0) + }) + private var locations: [FileLocationPath] + var source: [FileLocationPath] { + if locations.count > 1 { + return locations.dropLast() + } else { + return locations + } + } + var destination: FileLocationPath { + if locations.count <= 1 { + return try! FileLocationPath(".") + } else { + return locations.last! + } + } var preserveFlags: CopyAttributesFlag { preserve ? CopyAttributesFlag([.permissions, .timestamp]) : CopyAttributesFlag([]) @@ -166,6 +180,7 @@ class FileLocationPath { public class BlinkCopy: NSObject { var copyCancellable: AnyCancellable? + let device: TermDevice = tty() let currentRunLoop = RunLoop.current var stdout = OutputStream(file: thread_stdout) @@ -201,57 +216,44 @@ public class BlinkCopy: NSObject { // Connect to the destination first, as it will be the one driving the operation. let destProtocol = command.destination.proto ?? defaultRemoteProtocol - let destTranslator = (destProtocol == .local) ? localTranslator(to: command.destination.filePath) : + var destTranslator: AnyPublisher? = (destProtocol == .local) ? localTranslator(to: command.destination.filePath) : remoteTranslator(toFilePath: command.destination.filePath, atHost: command.destination.hostPath!, using: destProtocol, isSource: false) // Source - let sourceProtocol = command.source.proto ?? defaultRemoteProtocol - let sourceTranslator = (sourceProtocol == .local) ? localTranslator(to: command.source.filePath) : - remoteTranslator(toFilePath: command.source.filePath, atHost: command.source.hostPath!, using: sourceProtocol) + var sourceTranslators: AnyPublisher? = command.source.publisher.flatMap { source in + let sourceProtocol = source.proto ?? defaultRemoteProtocol + let rootTranslator = (sourceProtocol == .local) ? self.localTranslator(to: source.filePath) : + self.remoteTranslator(toFilePath: source.filePath, atHost: source.hostPath!, using: sourceProtocol) + + return rootTranslator.flatMap { t -> AnyPublisher in + t.translatorsMatching(path: source.filePath) + }.eraseToAnyPublisher() + }.eraseToAnyPublisher() - // TODO Output object for reports var rc: Int32 = 0 var rootFilePath: String! var currentFile = "" var displayFileName = "" var currentCopied: UInt64 = 0 var currentSpeed: String? - var sourceBasePath: String? var startTimestamp = 0 var lastElapsed = 0 - copyCancellable = destTranslator.flatMap { d -> CopyProgressInfoPublisher in + copyCancellable = destTranslator!.flatMap { d -> CopyProgressInfoPublisher in rootFilePath = d.current - - return sourceTranslator - .flatMap { - $0.cloneWalkTo(self.command.source.filePath) - } - .flatMap { - $0.translatorsMatching(path: self.command.source.filePath) - } - .reduce([] as [Translator]) { (all, t) in - var new = all - new.append(t) - return new - } - .tryMap { source -> [Translator] in - if source.count == 0 { - throw CommandError(message: "Source not found") - } - return source - } - .flatMap { source -> AnyPublisher<([Translator], Translator), Error> in + + return sourceTranslators! + .flatMap(maxPublishers: .max(1)) { source -> AnyPublisher<(Translator, Translator), Error> in // Walk on destination, and it may have to be a directory or a file. return d.cloneWalkTo(self.command.destination.filePath) .tryCatch { error -> AnyPublisher in // If we are copying a single item, then we can create a file for it. - guard source.count == 1 else { + guard self.command.source.count == 1 else { throw error } let newFileName = (self.command.destination.filePath as NSString).lastPathComponent let parentPath = (self.command.destination.filePath as NSString).deletingLastPathComponent return d.cloneWalkTo(parentPath) - .flatMap { $0.create(name: newFileName, flags: O_WRONLY, mode: S_IRWXU) } + .flatMap { $0.create(name: newFileName, mode: S_IRWXU) } .flatMap { $0.close() } .flatMap { _ in d.cloneWalkTo(self.command.destination.filePath) } .eraseToAnyPublisher() @@ -260,14 +262,15 @@ public class BlinkCopy: NSObject { .eraseToAnyPublisher() } .flatMap { - $1.copy(from: $0, args: copyArguments) + $1.copy(from: [$0], args: copyArguments) }.eraseToAnyPublisher() }.sink(receiveCompletion: { completion in if case let .failure(error) = completion { print("Copy failed. \(error)", to: &self.stderr) rc = -1 } - awake(runLoop: self.currentRunLoop) + + self.stop() }, receiveValue: { progress in //(file, size, written) in // ProgressReport object, which we can use here or at the Dashboard. if currentFile != progress.name { @@ -306,11 +309,14 @@ public class BlinkCopy: NSObject { } }) - awaitRunLoop(currentRunLoop) + // Run everything in its own loop... + CFRunLoopRunInMode(.defaultMode, TimeInterval(INT_MAX), false) - // Make another run on the loop to close extra stuff in blocks. + // ...and because of that, make another run after cleanup to let hanging self-loops close. + copyCancellable = nil + sourceTranslators = nil + destTranslator = nil RunLoop.current.run(until: Date(timeIntervalSinceNow: 0.5)) - return rc } @@ -339,8 +345,7 @@ public class BlinkCopy: NSObject { return .fail(error: CommandError(message: message)) } - return SSHClient.dial(host.hostName ?? sshCommand.hostAlias, with: config) - //return SSHPool.dial(hostName, with: config, connectionOptions: sshOptions) + return SSHClient.dial(host.hostName ?? sshCommand.hostAlias, with: config, withProxy: BlinkSSH.executeProxyCommand) .flatMap { $0.requestSFTP() } .tryMap { try SFTPTranslator(on: $0) } .eraseToAnyPublisher() @@ -350,8 +355,12 @@ public class BlinkCopy: NSObject { // Make signals objc funcs so we can duck type them. @objc func kill() { - copyCancellable?.cancel() + print("\r\nOperation cancelled", to: &self.stderr) + copyCancellable = nil + stop() + } - awake(runLoop: currentRunLoop) + func stop() { + CFRunLoopStop(self.currentRunLoop.getCFRunLoop()) } } diff --git a/Blink/Commands/ssh/Helpers.swift b/Blink/Commands/ssh/Helpers.swift index 6ddb126e1..64e33b5be 100644 --- a/Blink/Commands/ssh/Helpers.swift +++ b/Blink/Commands/ssh/Helpers.swift @@ -31,7 +31,7 @@ import Foundation - +import ios_system public typealias Argv = UnsafeMutablePointer?>? @@ -92,16 +92,3 @@ func tty() -> TermDevice { let session = Unmanaged.fromOpaque(thread_context).takeUnretainedValue() return session.device } - -func awaitRunLoop(_ runLoop: RunLoop) { - let timer = Timer(timeInterval: TimeInterval(INT_MAX), repeats: true) { _ in - print("timer") - } - runLoop.add(timer, forMode: .default) - CFRunLoopRun() -} - -func awake(runLoop: RunLoop) { - let cfRunLoop = runLoop.getCFRunLoop() - CFRunLoopStop(cfRunLoop) -} diff --git a/Blink/Commands/ssh/SSHAgentAdd.swift b/Blink/Commands/ssh/SSHAgentAdd.swift index 2c6dc6162..315d44ec2 100644 --- a/Blink/Commands/ssh/SSHAgentAdd.swift +++ b/Blink/Commands/ssh/SSHAgentAdd.swift @@ -34,46 +34,49 @@ import Foundation import ArgumentParser import SSH +import ios_system struct BlinkSSHAgentAddCommand: ParsableCommand { static var configuration = CommandConfiguration( - abstract: "Blink Agent Control", + commandName: "ssh-agent", + abstract: "Blink Default Agent Control", discussion: """ + You can also configure the default agent from Settings > Agent. """, version: "1.0.0" ) - + @Flag(name: [.customShort("L")], help: "List keys stored on agent") var list: Bool = false - + @Flag(name: [.customShort("l")], help: "Lists fingerprints of keys stored on agent") var listFingerprints: Bool = false - + // Remove @Flag(name: [.customShort("d")], help: "Remove key from agent") var remove: Bool = false - + // Hash algorithm @Option( name: [.customShort("E")], help: "Specify hash algorithm used for fingerprints" ) var hashAlgorithm: String = "sha256" - - @Flag(name: [.customShort("c")], - help: "Confirm before using identity" - ) - var askConfirmation: Bool = false + + // @Flag(name: [.customShort("c")], + // help: "Confirm before using identity" + // ) + // var askConfirmation: Bool = false @Argument(help: "Key name") var keyName: String? - - @Argument(help: "Agent name") - var agentName: String? + + // @Argument(help: "Agent name") + // var agentName: String? } @_cdecl("blink_ssh_add") @@ -89,42 +92,46 @@ public func blink_ssh_add(argc: Int32, argv: Argv) -> Int32 { public class BlinkSSHAgentAdd: NSObject { var command: BlinkSSHAgentAddCommand! - + var stdout = OutputStream(file: thread_stdout) var stderr = OutputStream(file: thread_stderr) let currentRunLoop = RunLoop.current - + public func start(_ argc: Int32, argv: [String], session: MCPSession) -> Int32 { - let bkConfig: BKConfig do { - bkConfig = try BKConfig() command = try BlinkSSHAgentAddCommand.parse(Array(argv[1...])) } catch { let message = BlinkSSHAgentAddCommand.message(for: error) print(message, to: &stderr) return -1 } - + + guard let defaultAgent = SSHDefaultAgent.instance else { + print("Default Agent is not available.", to: &stderr) + return -1 + } + if command.remove { let keyName = command.keyName ?? "id_rsa" - if let _ = SSHAgentPool.removeKey(named: keyName) { + do { + let _ = try SSHDefaultAgent.removeKey(named: keyName) print("Key \(keyName) removed.", to: &stdout) return 0 - } else { - print("Key not found on Agent", to: &stderr) + } catch { + print("Couldn't remove key: \(error)", to: &stderr) return -1 } } - + if command.list { - for key in SSHAgentPool.get()?.ring ?? [] { + for key in defaultAgent.ring { let str = BKPubKey.withID(key.name)?.publicKey ?? "" print("\(str) \(key.name)", to: &stdout) } - + return 0; } - + if command.listFingerprints { guard let alg = SSHDigest(rawValue: command.hashAlgorithm) @@ -132,37 +139,26 @@ public class BlinkSSHAgentAdd: NSObject { print("Invalid hash algorithm \"\(command.hashAlgorithm)\"", to: &stderr) return -1; } - - for key in SSHAgentPool.get()?.ring ?? [] { + + for key in defaultAgent.ring { if let blob = try? key.signer.publicKey.encode()[4...], let sshkey = try? SSHKey(fromPublicBlob: blob) { let str = sshkey.fingerprint(digest: alg) - + print("\(sshkey.size) \(str) \(key.name) (\(sshkey.sshKeyType.shortName))", to: &stdout) } } return 0 } - - // TODO Can we have the same key under different constraints? - + // Default case: add key - if let (signer, name) = bkConfig.signer(forIdentity: command.keyName ?? "id_rsa") { - if let signer = signer as? BlinkConfig.InputPrompter { - signer.setPromptOnView(session.device.view) - } - var constraints: [SSHAgentConstraint]? = nil - if command.askConfirmation { - constraints = [SSHAgentUserPrompt()] - } - - SSHAgentPool.addKey(signer, named: name, constraints: constraints) - print("Key \(name) - added to agent.", to: &stdout) + do { + try SSHDefaultAgent.addKey(named: command.keyName ?? "id_rsa") return 0 - } else { - print("Key not found", to: &stderr) - return -1 + } catch { + print("Could not add key \(error)", to: &stderr) + return -1; } } } diff --git a/Blink/Commands/ssh/SSHAgentPool.swift b/Blink/Commands/ssh/SSHAgentPool.swift deleted file mode 100644 index 55c509c21..000000000 --- a/Blink/Commands/ssh/SSHAgentPool.swift +++ /dev/null @@ -1,74 +0,0 @@ -////////////////////////////////////////////////////////////////////////////////// -// -// B L I N K -// -// Copyright (C) 2016-2019 Blink Mobile Shell Project -// -// This file is part of Blink. -// -// Blink is free software: you can redistribute it and/or modify -// it under the terms of the GNU General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// Blink is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with Blink. If not, see . -// -// In addition, Blink is also subject to certain additional terms under -// GNU GPL version 3 section 7. -// -// You should have received a copy of these additional terms immediately -// following the terms and conditions of the GNU General Public License -// which accompanied the Blink Source Code. If not, see -// . -// -//////////////////////////////////////////////////////////////////////////////// - - -import Foundation - -import SSH - - -public let DefaultAgentName = "default" - -final class SSHAgentPool { - private static let shared = SSHAgentPool() - public static let defaultAgent = SSHAgentPool.shared.agent(DefaultAgentName) - - private var agents: [String:SSHAgent] = [:] - - private init() {} - - func agent(_ name: String) -> SSHAgent { - if let agent = agents[name] { - return agent - } else { - let agent = SSHAgent() - agents[name] = agent - return agent - } - } - - static func get(agent name: String = DefaultAgentName) -> SSHAgent? { - return Self.shared.agents[name] - } - - static func addKey(_ key: Signer, named keyName: String, constraints: [SSHAgentConstraint]? = nil, toAgent agentName: String = DefaultAgentName) { - let agent = Self.shared.agent(agentName) - agent.loadKey(key, aka: keyName, constraints: constraints) - } - - static func removeKey(named keyName: String, fromAgent agentName: String = DefaultAgentName) -> Signer? { - guard let agent = Self.shared.agents[agentName] else { - return nil - } - - return agent.removeKey(keyName) - } -} diff --git a/Blink/Commands/ssh/SSHAgentUserPrompt.swift b/Blink/Commands/ssh/SSHAgentUserPrompt.swift index b307eed79..cc737db37 100644 --- a/Blink/Commands/ssh/SSHAgentUserPrompt.swift +++ b/Blink/Commands/ssh/SSHAgentUserPrompt.swift @@ -51,12 +51,7 @@ public class SSHAgentUserPrompt: SSHAgentConstraint { let semaphore = DispatchSemaphore(value: 0) var shouldForwardKey: Bool = false - // TODO Figure out the proper control - // If we requested permission using a notification, once the notification disappears, there - // would be no way of doing it. - // But the Alert controller blocks if you are working somewhere else. - // We need a combo, and use a custom UI instead of the UIAlertController. - // If the connection is reused, the view may also come from the wrong place. + let alert = UIAlertController(title: "Agent", message: "Forward key \"\(key.name)\" on \(client.host)?", preferredStyle: .alert) alert.addAction( UIAlertAction(title: NSLocalizedString("Forward Once", comment: "Forward the key this time"), diff --git a/Blink/Commands/ssh/SSHConfig.swift b/Blink/Commands/ssh/SSHConfig.swift index db5c0d65f..2c991792a 100644 --- a/Blink/Commands/ssh/SSHConfig.swift +++ b/Blink/Commands/ssh/SSHConfig.swift @@ -39,6 +39,7 @@ fileprivate let Version = "1.0.0" struct SSHCommand: ParsableCommand { static var configuration = CommandConfiguration( // Optional abstracts and discussions are used for help output. + commandName: "ssh", abstract: "A LibSSH SSH client (remote login program)", discussion: """ ssh (SSH client) is a program for logging into a remote machine and for executing commands on a remote machine. It is intended to replace rlogin and rsh, and provide secure encrypted communications between two untrusted hosts over an insecure network. @@ -49,6 +50,41 @@ struct SSHCommand: ParsableCommand { // Commands can define a version for automatic '--version' support. version: Version) + // Connect to User at Host + @Argument(help: "[user@]host[#port]") + var userAtHost: String + var hostAlias: String { + get { + let comps = userAtHost.components(separatedBy: "@") + let hostAndPort = comps[comps.count - 1] + let compsHost = hostAndPort.components(separatedBy: "#") + return compsHost[0] + } + } + var user: String? { + get { + // Login name preference over user@host + if let user = loginName { + return user + } + var comps = userAtHost.components(separatedBy: "@") + if comps.count > 1 { + comps.removeLast() + return comps.joined(separator: "@") + } + return nil + } + } + var port: UInt16? { + get { + if let port = customPort { + return port + } + let comps = userAtHost.components(separatedBy: "#") + return comps.count > 1 ? UInt16(comps[1]) : nil + } + } + // Port forwarding options @Option(name: .customShort("L"), help: ":: Specifies that the given port on the local (client) host is to be forwarded to the given host and port on the remote side." @@ -56,7 +92,7 @@ struct SSHCommand: ParsableCommand { var localForward: [String] = [] // Remote Port forwarding - @Option(name: [.customShort("R")], + @Option(name: [.customShort("R")], help: "port:host:hostport Specifies that the given port on the remote (server) host is to be forwarded to the given host and port on the local side." ) var remoteForward: [String] = [] @@ -198,43 +234,8 @@ struct SSHCommand: ParsableCommand { ) var identityFile: String? - // Connect to User at Host - @Argument(help: "[user@]host[#port]") - var userAtHost: String - var hostAlias: String { - get { - let comps = userAtHost.components(separatedBy: "@") - let hostAndPort = comps[comps.count - 1] - let compsHost = hostAndPort.components(separatedBy: "#") - return compsHost[0] - } - } - var user: String? { - get { - // Login name preference over user@host - if let user = loginName { - return user - } - var comps = userAtHost.components(separatedBy: "@") - if comps.count > 1 { - comps.removeLast() - return comps.joined(separator: "@") - } - return nil - } - } - var port: UInt16? { - get { - if let port = customPort { - return port - } - let comps = userAtHost.components(separatedBy: "#") - return comps.count > 1 ? UInt16(comps[1]) : nil - } - } - @Argument( - parsing: .unconditionalRemaining, + parsing: .remaining, help: .init( "If a is specified, it is executed on the remote host instead of a login shell", valueName: "command" @@ -302,7 +303,7 @@ extension SSHCommand { if !self.dynamicForward.isEmpty { params["dynamicforward"] = dynamicForward } - + if agentForward { params["forwardagent"] = "yes" } @@ -346,3 +347,26 @@ enum SSHControlCommands: String, CaseIterable, ExpressibleByArgument { case cancel = "cancel" case stop = "stop" } + +class UserAtHostAndPort { + let user: String? + let hostAlias: String + let port: UInt16? + + init(_ input: String) { + var userAtHost = input.components(separatedBy: "@") + let hostAndPort: String + if userAtHost.count > 1 { + hostAndPort = userAtHost.removeLast() + // A user may have multiple @ symbols + self.user = userAtHost.joined(separator: "@") + } else { + self.user = nil + hostAndPort = userAtHost[0] + } + + var hostAndPortComponents = hostAndPort.components(separatedBy: "#") + self.hostAlias = hostAndPortComponents.removeFirst() + self.port = if hostAndPortComponents.count == 0 { nil } else { UInt16(hostAndPortComponents[0]) } + } +} diff --git a/Blink/Commands/ssh/SSHConfigProvider.swift b/Blink/Commands/ssh/SSHConfigProvider.swift index d18badb48..9e40a8782 100644 --- a/Blink/Commands/ssh/SSHConfigProvider.swift +++ b/Blink/Commands/ssh/SSHConfigProvider.swift @@ -61,7 +61,7 @@ class SSHClientConfigProvider { // Return HostName, SSHClientConfig for the server static func config(host: BKSSHHost, using device: TermDevice) throws -> SSHClientConfig { let prov = try SSHClientConfigProvider(using: device) - + let agent = prov.agent(for: host) let availableAuthMethods: [AuthMethod] = [AuthAgent(agent)] + prov.passwordAuthMethods(for: host) @@ -134,16 +134,20 @@ extension SSHClientConfigProvider { signers.forEach { (signer, name) in // NOTE We could also keep the reference and just read the key at the proper time. - // TODO Errors. Either pass or log here, or if we create a different - // type of key, then let the Agent fail. if let signer = signer as? BlinkConfig.InputPrompter { signer.setPromptOnView(device.view) + signer.setLogger(self.logger, verbosity: host.logLevel ?? .none) } agent.loadKey(signer, aka: name, constraints: consts) } // Link to Default Agent - agent.linkTo(agent: SSHAgentPool.defaultAgent) + if let defaultAgent = SSHDefaultAgent.instance { + agent.linkTo(agent: defaultAgent) + } else { + printLn("Default agent is not available.") + } + return agent } diff --git a/Blink/Commands/ssh/SSHDefaultAgent.swift b/Blink/Commands/ssh/SSHDefaultAgent.swift new file mode 100644 index 000000000..96913522d --- /dev/null +++ b/Blink/Commands/ssh/SSHDefaultAgent.swift @@ -0,0 +1,200 @@ +////////////////////////////////////////////////////////////////////////////////// +// +// B L I N K +// +// Copyright (C) 2016-2019 Blink Mobile Shell Project +// +// This file is part of Blink. +// +// Blink is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Blink is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Blink. If not, see . +// +// In addition, Blink is also subject to certain additional terms under +// GNU GPL version 3 section 7. +// +// You should have received a copy of these additional terms immediately +// following the terms and conditions of the GNU General Public License +// which accompanied the Blink Source Code. If not, see +// . +// +//////////////////////////////////////////////////////////////////////////////// + + +import Foundation +import SSH + + +final class SSHDefaultAgent { + public static var instance: SSHAgent? { + if let agent = Self._instance { + return agent + } else { + return Self.load() + } + } + private static var _instance: SSHAgent? = nil + private init() {} + // The pool is responsible for the location of Agents. + private static let defaultAgentFile: URL = BlinkPaths.blinkAgentSettingsURL().appendingPathComponent("default") + + enum Error: Swift.Error, LocalizedError { + case KeyIsMissing + + var localizedDescription: String { + switch self { + case .KeyIsMissing: + "Key is missing" + } + } + } + + // Load the (default) agent in the pool. If the Agent cannot be loaded, it will be unavailable (nil). + // If the agent doesn't exist, it will be initialized (default only). + private static func load() -> SSHAgent? { + do { + if let settings = try getSettings() { + try setAgentInstance(with: settings) + } else { + try setSettings(BKAgentSettings()) + } + } catch { + return nil + } + + return Self._instance + } + + // Create the settings for the agent and load it in the pool. + // NOTE: For non-default agents, this would be the main initialization method. + static func setSettings(_ settings: BKAgentSettings) throws { + try BKAgentSettings.save(settings: settings, to: defaultAgentFile) + try setAgentInstance(with: settings) + } + + private static func setAgentInstance(with settings: BKAgentSettings) throws { + let bkConfig = try BKConfig() + + let agent: SSHAgent + if let _instance = Self._instance { + agent = _instance + agent.clear() + } else { + agent = SSHAgent() + Self._instance = agent + } + + settings.keys.forEach { key in + if let (signer, name) = bkConfig.signer(forIdentity: key) { + if let constraints = settings.constraints() { + agent.loadKey(signer, aka: name, constraints: constraints) + } + } + } + } + + static func getSettings() throws -> BKAgentSettings? { + try BKAgentSettings.read(from: defaultAgentFile) + } + + // Applying settings clears the agent first. Adding a key doesn't modify or reset previous constraints. + static func addKey(named keyName: String) throws { + guard let agent = Self.instance, + let settings = try getSettings() else { + return + } + + if settings.keys.contains(keyName) { + return + } + + let bkConfig = try BKConfig() + + if let (signer, name) = bkConfig.signer(forIdentity: keyName) { + var keys = settings.keys + keys.append(keyName) + let newSettings = BKAgentSettings(prompt: settings.prompt, keys: keys) + + do { + try BKAgentSettings.save(settings: newSettings, to: defaultAgentFile) + } catch { + } + + if let constraints = settings.constraints() { + agent.loadKey(signer, aka: keyName, constraints: constraints) + } + } else { + throw Error.KeyIsMissing + } + } + + static func removeKey(named keyName: String) throws -> Signer? { + // Remove from settings and apply + guard let agent = Self.instance, + let settings = try getSettings(), + settings.keys.contains(keyName) else { + return nil + } + + var keys = settings.keys + keys.removeAll(where: { $0 == keyName }) + try BKAgentSettings.save(settings: BKAgentSettings(prompt: settings.prompt, keys: keys), to: defaultAgentFile) + + return agent.removeKey(keyName) + } +} + +// NOTE Another way to represent the Agent would be to just share the current state by reading the file, +// instead of having the state stored in a variable. This would be best for concurrency too, but that shouldn't +// be a problem now. + +enum BKAgentSettingsPrompt: String, Codable { + case Deny = "Deny" + case Confirm = "Confirm" + case Allow = "Allow" +} + +struct BKAgentSettings: Codable, Equatable { + let prompt: BKAgentSettingsPrompt + let keys: [String] + + init(prompt: BKAgentSettingsPrompt, keys: [String]) { + self.prompt = prompt + self.keys = keys + } + + init() { self = Self(prompt: .Confirm, keys: []) } + + static func save(settings: BKAgentSettings, to file: URL) throws { + let data = try JSONEncoder().encode(settings) + try data.write(to: file) + } + + fileprivate static func read(from file: URL) throws -> BKAgentSettings? { + guard FileManager.default.fileExists(atPath: file.path) else { + return nil + } + let data = try Data(contentsOf: file) + return try JSONDecoder().decode(BKAgentSettings.self, from: data) + } + + func constraints() -> [SSHAgentConstraint]? { + return switch prompt { + case .Confirm: + [SSHAgentUserPrompt()] + case .Allow: + [] + default: + nil + } + } +} diff --git a/Blink/Commands/ssh/SSHPool.swift b/Blink/Commands/ssh/SSHPool.swift index c09b224d1..7e1adb15e 100644 --- a/Blink/Commands/ssh/SSHPool.swift +++ b/Blink/Commands/ssh/SSHPool.swift @@ -41,12 +41,13 @@ import SSH class SSHPool { static let shared = SSHPool() private var controls: [SSHClientControl] = [] - + private let queue = DispatchQueue(label: "SSHPoolControlQueue", attributes: .concurrent) + private init() {} - static func dial(_ host: String, - with config: SSHClientConfig, - withControlMaster: ControlMasterOption = .no, + static func dial(_ host: String, + with config: SSHClientConfig, + withControlMaster: ControlMasterOption = .no, withProxy proxy: SSH.SSHClient.ExecProxyCommandCallback? = nil) -> AnyPublisher { // Do not use an existing socket. @@ -55,39 +56,45 @@ class SSHPool { // For now we will not allow that situation. return shared.startConnection(host, with: config, proxy: proxy, exposeSocket: false) } - guard let ctrl = shared.control(for: host, with: config) else { - return shared.startConnection(host, with: config, proxy: proxy) - } - - guard let conn = ctrl.connection, conn.isConnected else { - shared.removeControl(ctrl) - return shared.startConnection(host, with: config, proxy: proxy) + if let ctrl = shared.control(for: host, with: config) { + if let conn = ctrl.connection, conn.isConnected { + return .just(conn) + } else { + shared.removeControl(ctrl) + } } - - return .just(conn) + + return shared.startConnection(host, with: config, proxy: proxy) } private func startConnection(_ host: String, with config: SSHClientConfig, proxy: SSH.SSHClient.ExecProxyCommandCallback? = nil, exposeSocket exposed: Bool = true) -> AnyPublisher { let pb = PassthroughSubject() - var cancel: AnyCancellable? + var dial: AnyCancellable? var runLoop: RunLoop! let t = Thread { runLoop = RunLoop.current - cancel = SSH.SSHClient.dial(host, with: config, withProxy: proxy) - .sink(receiveCompletion: { pb.send(completion: $0) }, - receiveValue: { conn in - let control = SSHClientControl(for: conn, on: host, with: config, running: runLoop, exposed: exposed) - SSHPool.shared.controls.append(control) - pb.send(conn) - }) - - awaitRunLoop(runLoop) - // Make another run on the loop to close extra stuff in blocks. - RunLoop.current.run(until: Date(timeIntervalSinceNow: 0.5)) + dial = SSH.SSHClient.dial(host, with: config, withProxy: proxy) + //.print("SSHClient Pool") + .sink( + receiveCompletion: { completion in + pb.send(completion: completion) + }, + receiveValue: { [weak self] conn in + let control = SSHClientControl(for: conn, on: host, with: config, running: runLoop, exposed: exposed) + self?.queue.sync { + SSHPool.shared.controls.append(control) + } + pb.send(conn) + }) + + // NOTE Before we let the Pool control the RunLoop, and the problem is that SSHClient needs to be in full control + // as it may have multiple nested runs internally. Using SSHClient.run, the SSHClient will continue until the object itself is fully disposed. + SSH.SSHClient.run() + print("Pool Thread out") } @@ -95,7 +102,7 @@ class SSHPool { return pb.buffer(size: 1, prefetch: .byRequest, whenFull: .dropOldest) .handleEvents(receiveCancel: { - cancel?.cancel() + dial = nil }).eraseToAnyPublisher() } @@ -104,28 +111,27 @@ class SSHPool { } private static func control(on connection: SSH.SSHClient) -> SSHClientControl? { - shared.controls.first { $0.connection === connection } + shared.control(on: connection) } - + + private func control(on connection: SSH.SSHClient) -> SSHClientControl? { + queue.sync { + controls.first { $0.connection === connection } + } + } + private func control(for host: String, with config: SSHClientConfig) -> SSHClientControl? { - return controls.first { $0.isConnection(for: host, with: config) } + queue.sync { + controls.first { $0.isConnection(for: host, with: config) } + } } - + private func enforcePersistance(_ control: SSHClientControl) { print("Current channels \(control.numChannels)") print("\(control.localTunnels)") print("\(control.remoteTunnels)") if control.numChannels == 0 { - // For now, we just stop the connection as is - // We could use a delegate just to notify when a connection is dead, and the control could - // take care of figuring out when the connection it contains must go. - awake(runLoop: control.runLoop) - let idx = controls.firstIndex { $0 === control }! - - // Removing references to connection to deinit. - // We could also handle the pool with references to the connection. - // But the shell or time based persistance may become more difficult. - controls.remove(at: idx) + self.removeControl(control) } } } @@ -143,6 +149,7 @@ extension SSHPool { // NOTE This is a workaround c.streams.forEach { (_, s) in s.cancel() } c.streams = [] + shared.enforcePersistance(c) } } @@ -155,7 +162,7 @@ extension SSHPool { c.numShells += 1 } } - + static func deregister(shellOn connection: SSH.SSHClient) { guard let c = control(on: connection) else { return @@ -167,13 +174,13 @@ extension SSHPool { // Forward Tunnels extension SSHPool { - static func register(_ listener: SSHPortForwardListener, - portForwardInfo: PortForwardInfo, + static func register(_ listener: SSHPortForwardListener, + portForwardInfo: PortForwardInfo, on connection: SSH.SSHClient) { let c = control(on: connection) c?.localTunnels[portForwardInfo] = listener } - + static func deregister(localForward: PortForwardInfo, on connection: SSH.SSHClient) { guard let c = control(on: connection) else { return @@ -188,15 +195,15 @@ extension SSHPool { guard let c = control(on: connection) else { return false } - + return c.localTunnels[localForward] != nil } } // Remote Tunnels extension SSHPool { - static func register(_ client: SSHPortForwardClient, - portForwardInfo: PortForwardInfo, + static func register(_ client: SSHPortForwardClient, + portForwardInfo: PortForwardInfo, on connection: SSH.SSHClient) { let c = control(on: connection) c?.remoteTunnels[portForwardInfo] = client @@ -216,7 +223,7 @@ extension SSHPool { guard let c = control(on: connection) else { return false } - + return c.remoteTunnels[remoteForward] != nil } } @@ -244,7 +251,7 @@ extension SSHPool { guard let c = control(on: connection) else { return false } - + return c.socks[socksBindAddress] != nil } } @@ -254,15 +261,23 @@ extension SSHPool { let c = control(on: connection) c?.streams.append((command, stream)) } - + private func removeControl(_ control: SSHClientControl) { - awake(runLoop: control.runLoop) - guard - let idx = controls.firstIndex(where: { $0 === control }) - else { - return + queue.async(flags: .barrier) { + // For now, we just stop the connection as is + // We could use a delegate just to notify when a connection is dead, and the control could + // take care of figuring out when the connection it contains must go. + guard + let idx = self.controls.firstIndex(where: { $0 === control }) + else { + return + } + + // Removing references to connection to deinit. + // We could also handle the pool with references to the connection. + // But the shell or time based persistance may become more difficult. + self.controls.remove(at: idx) } - controls.remove(at: idx) } } @@ -272,7 +287,7 @@ fileprivate class SSHClientControl { let config: SSHClientConfig let runLoop: RunLoop let exposed: Bool - + var numShells: Int = 0 //var shells: [(SSHCommand, SSH.Stream)] = [] @@ -287,7 +302,7 @@ fileprivate class SSHClientControl { return numShells + streams.count + localTunnels.count + remoteTunnels.count + socks.count } } - + init(for connection: SSH.SSHClient, on host: String, with config: SSHClientConfig, running runLoop: RunLoop, exposed: Bool) { self.connection = connection self.host = host @@ -295,8 +310,8 @@ fileprivate class SSHClientControl { self.runLoop = runLoop self.exposed = exposed } - - + + // Other parameters could specify how the connection should be treated by the pool // (timeouts, etc...) func isConnection(for host: String, with config: SSHClientConfig) -> Bool { @@ -307,7 +322,7 @@ fileprivate class SSHClientControl { return self.host == host && config == self.config ? true : false } } -/* +/* fileprivate protocol TunnelControl { func close() } @@ -330,4 +345,3 @@ extension OptionalBindAddressInfo: Hashable { hasher.combine(self.port) } } - diff --git a/Blink/Commands/ssh/ssh.swift b/Blink/Commands/ssh/ssh.swift index 3607209ba..41c50dc6a 100644 --- a/Blink/Commands/ssh/ssh.swift +++ b/Blink/Commands/ssh/ssh.swift @@ -41,7 +41,7 @@ public func blink_ssh_main(argc: Int32, argv: Argv) -> Int32 { setvbuf(thread_stdin, nil, _IONBF, 0) setvbuf(thread_stdout, nil, _IONBF, 0) setvbuf(thread_stderr, nil, _IONBF, 0) - + let session = Unmanaged.fromOpaque(thread_context).takeUnretainedValue() let cmd = BlinkSSH(mcp: session) return cmd.start(argc, argv: argv.args(count: argc)) @@ -52,6 +52,7 @@ public func blink_ssh_main(argc: Int32, argv: Argv) -> Int32 { var outstream: Int32 var instream: Int32 + var errstream: Int32 let device: TermDevice var isTTY: Bool var stdout = OutputStream(file: thread_stdout) @@ -59,7 +60,7 @@ public func blink_ssh_main(argc: Int32, argv: Argv) -> Int32 { private var _mcp: MCPSession; var exitCode: Int32 = 0 - var cancellableBag: Set = [] + var connectionCancellable: AnyCancellable? let currentRunLoop = RunLoop.current var command: SSHCommand? var stream: SSH.Stream? @@ -68,14 +69,18 @@ public func blink_ssh_main(argc: Int32, argv: Argv) -> Int32 { var remoteTunnels: [PortForwardInfo] = [] var proxyThread: Thread? var socks: [OptionalBindAddressInfo] = [] + var timer: Timer? var outStream: DispatchOutputStream? var inStream: DispatchInputStream? + var errStream: DispatchOutputStream? init(mcp: MCPSession) { _mcp = mcp; + // Owed by ios_system, so beware to dup before using. self.outstream = fileno(thread_stdout) self.instream = fileno(thread_stdin) + self.errstream = fileno(thread_stderr) self.device = tty() self.isTTY = ios_isatty(self.instream) != 0 super.init() @@ -160,7 +165,7 @@ public func blink_ssh_main(argc: Int32, argv: Argv) -> Int32 { return } self._mcp.setActiveSession() - self.executeProxyCommand(command: $0, sockIn: $1, sockOut: $2) + Self.executeProxyCommand(command: $0, sockIn: $1, sockOut: $2) }) } @@ -173,9 +178,19 @@ public func blink_ssh_main(argc: Int32, argv: Argv) -> Int32 { } }) - connect.flatMap { conn -> SSHConnection in + connectionCancellable = connect.flatMap { conn -> SSHConnection in self.connection = conn + if let banner = conn.issueBanner, + !banner.isEmpty { + print(banner, to: &self.stdout) + } + + conn.handleSessionException = { error in + print("Exception received \(error)", to: &self.stderr) + self.kill() + } + if cmd.startsSession { if let addr = conn.clientAddressIP() { print("Connected to \(addr)", to: &self.stdout) @@ -183,7 +198,10 @@ public func blink_ssh_main(argc: Int32, argv: Argv) -> Int32 { // AgentForwardingPrompt var sendAgent = host.forwardAgent ?? false - + // Add forwarded keys after the connection is established, to make sure they won't be used + // during login. + // TODO: We do not need to change the sendAgent flag here, but ssh_config was not adding it. + // Let configs change and do later. if let bkHost = BKHosts.withHost(cmd.hostAlias), let agent = conn.agent { if self.loadAgentForwardKeys(bkHost: bkHost, agent: agent) { @@ -221,19 +239,18 @@ public func blink_ssh_main(argc: Int32, argv: Argv) -> Int32 { self.kill() } }) - .store(in: &cancellableBag) - awaitRunLoop(currentRunLoop) + awaitRunLoop() stream?.cancel() outStream?.close() inStream?.close() - // Dispatch streams need a cycle to close. - RunLoop.current.run(until: Date(timeIntervalSinceNow: 0.1)) - - // Need to get rid of the stream because the channel needs a cycle to be closed. - self.stream = nil - + errStream?.close() + stream = nil + outStream = nil + inStream = nil + errStream = nil + if let conn = self.connection, cmd.blocks { if cmd.startsSession { SSHPool.deregister(shellOn: conn) } forwardTunnels.forEach { SSHPool.deregister(localForward: $0, on: conn) } @@ -241,10 +258,12 @@ public func blink_ssh_main(argc: Int32, argv: Argv) -> Int32 { socks.forEach { SSHPool.deregister(socksBindAddress: $0, on: conn) } } + connectionCancellable = nil + self.connection = nil return exitCode } - private func executeProxyCommand(command: String, sockIn: Int32, sockOut: Int32) { + static func executeProxyCommand(command: String, sockIn: Int32, sockOut: Int32) { /* Prepare /dev/null socket for the stderr redirection */ let devnull = open("/dev/null", O_WRONLY); if devnull == -1 { @@ -295,24 +314,29 @@ public func blink_ssh_main(argc: Int32, argv: Argv) -> Int32 { } return session.tryMap { s in - let outs = DispatchOutputStream(stream: self.outstream) - let ins = DispatchInputStream(stream: self.instream) + let outs = DispatchOutputStream(stream: dup(self.outstream)) + let ins = DispatchInputStream(stream: dup(self.instream)) + let errs = DispatchOutputStream(stream: dup(self.errstream)) - s.handleCompletion = { + s.handleCompletion = { [weak self] in // Once finished, exit. - self.kill() + self?.kill() return } - s.handleFailure = { error in + s.handleFailure = { [weak self] error in + guard let self = self else { + return + } self.exitCode = -1 print("Interactive Shell error. \(error)", to: &self.stderr) self.kill() return } - s.connect(stdout: outs, stdin: ins) + s.connect(stdout: outs, stdin: ins, stderr: errs) self.outStream = outs self.inStream = ins + self.errStream = errs SSHPool.register(shellOn: conn) self.stream = s return conn @@ -323,7 +347,7 @@ public func blink_ssh_main(argc: Int32, argv: Argv) -> Int32 { guard let tunnel = command.stdioHostAndPort else { return .just(conn) } - + return conn.requestForward(to: tunnel.bindAddress, port: Int32(tunnel.port), // Just informative. from: "stdio", localPort: 22) @@ -333,16 +357,16 @@ public func blink_ssh_main(argc: Int32, argv: Argv) -> Int32 { let inStream = DispatchInputStream(stream: dup(self.instream)) s.connect(stdout: outStream, stdin: inStream) - s.handleCompletion = { + s.handleCompletion = { [weak self] in print("Stdio Tunnel completed") SSHPool.deregister(allTunnelsForConnection: conn) - self.kill() + self?.kill() //SSHPool.deregister(runningCommand: command, on: conn) } - s.handleFailure = { error in + s.handleFailure = { [weak self] error in print("Stdio Tunnel completed") SSHPool.deregister(allTunnelsForConnection: conn) - self.kill() + self?.kill() //SSHPool.deregister(runningCommand: command, on: conn) } @@ -484,9 +508,24 @@ public func blink_ssh_main(argc: Int32, argv: Argv) -> Int32 { // Cancelling here makes sure the flows are cancelled. // Trying to do it at the runloop has the issue that flows may continue running. print("Kill received") - cancellableBag = [] + connectionCancellable = nil + + awake() + } + + func awaitRunLoop() { + let timer = Timer(timeInterval: TimeInterval(INT_MAX), repeats: true) { _ in + print("timer") + } + self.timer = timer + self.currentRunLoop.add(timer, forMode: .default) + CFRunLoopRun() + } - awake(runLoop: currentRunLoop) + func awake() { + let cfRunLoop = self.currentRunLoop.getCFRunLoop() + self.timer?.invalidate() + CFRunLoopStop(cfRunLoop) } deinit { diff --git a/Blink/Complete.swift b/Blink/Complete.swift index f0868d896..a67c54787 100644 --- a/Blink/Complete.swift +++ b/Blink/Complete.swift @@ -115,6 +115,7 @@ struct Complete { } let result = [ "awk": "Select particular records in a file and perform operations upon them.", + "bc": "Calculator 🧮.", "cat": "Concatenate and print files.", "cd": "Change directory.", // // "chflags": "chflags", // TODO @@ -128,7 +129,6 @@ struct Complete { "diff": "Compare files line by line.", "dig": "DNS lookup utility.", "du": "Disk usage", - "ed": "Line-oriented text editor", "echo": "Write arguments to the standard output.", "egrep": "Search for a pattern using extended regex.", // https://www.computerhope.com/unix/uegrep.htm "env": "Set environment and execute command, or print environment.", // fish @@ -142,6 +142,7 @@ struct Complete { "help": "Prints all commands. 🧐 ", "history": "Use -c option to clear history. 🙈 ", "host": "DNS lookup utility.", // fish + "less": "Pager.", "link": "Make links.", // fish // "ln": "", // TODO "ls": "List files and directories", @@ -184,10 +185,10 @@ struct Complete { "unlink": "Remove directory entries.", // fish // // @"unsetenv": @"", // TODO "uptime": "Show how long system has been running.", // fish + "vim": "Vi IMproved, a programmer's text editor", "wc": "Words and lines counter.", "whoami": "Display effective user id.", // fish "whois": "Internet domain name and network number directory service.", // fish - "open": "open url of file (Experimental). 📤", "link-files": "link folders from Files.app (Experimental).", "build": "Access to Blink dev machines. ⚒ ", @@ -201,10 +202,10 @@ struct Complete { private static func _completionKind(_ cmd: String) -> Kind { switch cmd { case "": return .command - case "ssh", "ssh2", "mosh": return .blinkHost + case "ssh", "ssh2", "mosh", "mosh1": return .blinkHost case "ping": return .host case "ls": return .directory - case "file": return .file + case "file", "vim", "less": return .file case "geo": return .blinkGeo case "build": return .blinkBuild case "facecam": return .facecam diff --git a/Blink/DeviceInfo.m b/Blink/DeviceInfo.m index 7281fbdcf..b806ebfc6 100644 --- a/Blink/DeviceInfo.m +++ b/Blink/DeviceInfo.m @@ -60,14 +60,33 @@ - (instancetype)init { NSString *marketingName = self.marketingName; - _hasNotch = [marketingName hasPrefix:@"iPhone X"] || [marketingName hasPrefix:@"iPhone 11"] || [marketingName hasPrefix:@"iPhone 12"] || [marketingName hasPrefix:@"iPhone 13"] || [marketingName hasPrefix:@"iPhone 14"]; - _hasDynamicIsland = [marketingName hasPrefix:@"iPhone 14"]; - _hasCorners = _hasNotch || [_machine hasPrefix:@"iPad8"] || [_machine hasPrefix:@"iPad13"] || [_machine hasPrefix:@"iPad14"] || [marketingName hasPrefix:@"Mac"]; - _hasAppleSilicon = [marketingName hasPrefix:@"iPad Pro (11-inch) (3rd generation)"] || - [marketingName hasPrefix:@"iPad Pro (11-inch) (4th generation)"] || - [marketingName hasPrefix:@"iPad Pro (12.9-inch) (5th generation)"] || - [marketingName hasPrefix:@"iPad Pro (12.9-inch) (6th generation)"] || - [marketingName hasPrefix:@"iPad Air (5th generation)"]; + _hasNotch = [marketingName hasPrefix:@"iPhone X"] + || [marketingName hasPrefix:@"iPhone 11"] + || [marketingName hasPrefix:@"iPhone 12"] + || [marketingName hasPrefix:@"iPhone 13"] + || [marketingName hasPrefix:@"iPhone 14"] + || [marketingName hasPrefix:@"iPhone 15"] + || [marketingName hasPrefix:@"iPhone 16"]; + + _hasDynamicIsland = [marketingName hasPrefix:@"iPhone 14"] + || [marketingName hasPrefix:@"iPhone 15"] + || [marketingName hasPrefix:@"iPhone 16"]; + + _hasCorners = _hasNotch || [_machine hasPrefix:@"iPad8"] + || [_machine hasPrefix:@"iPad13"] + || [_machine hasPrefix:@"iPad14"] + || [_machine hasPrefix:@"iPad16"] + || [marketingName containsString:@"(M2)"] + || [marketingName containsString:@"(M4)"] + || [marketingName hasPrefix:@"Mac"]; + + _hasAppleSilicon = [marketingName hasPrefix:@"iPad Pro (11-inch) (3rd generation)"] + || [marketingName hasPrefix:@"iPad Pro (11-inch) (4th generation)"] + || [marketingName hasPrefix:@"iPad Pro (12.9-inch) (5th generation)"] + || [marketingName hasPrefix:@"iPad Pro (12.9-inch) (6th generation)"] + || [marketingName hasPrefix:@"iPad Air (5th generation)"] + || [marketingName containsString:@"(M2)"] + || [marketingName containsString:@"(M4)"]; } return self; } @@ -178,6 +197,14 @@ -(NSString *)marketingName { @"iPhone14,8": @"iPhone 14 Plus", @"iPhone15,2": @"iPhone 14 Pro", @"iPhone15,3": @"iPhone 14 Pro Max", + @"iPhone15,4": @"iPhone 15", + @"iPhone15,5": @"iPhone 15 Plus", + @"iPhone16,1": @"iPhone 15 Pro", + @"iPhone16,2": @"iPhone 15 Pro Max", + @"iPhone17,1": @"iPhone 16 Pro", + @"iPhone17,2": @"iPhone 16 Pro Max", + @"iPhone17,3": @"iPhone 16", + @"iPhone17,4": @"iPhone 16 Plus", @"iPad4,1" : @"iPad Air", // 5th Generation iPad (iPad Air) - Wifi @@ -258,6 +285,16 @@ -(NSString *)marketingName { @"iPad14,4" : @"iPad Pro (11-inch) (4th generation)", @"iPad14,5" : @"iPad Pro (12.9-inch) (6th generation)", @"iPad14,6" : @"iPad Pro (12.9-inch) (6th generation)", + + @"iPad14,8" : @"iPad Air (11-inch) (M2)", // wifi + @"iPad14,9" : @"iPad Air (11-inch) (M2)", // cellular + @"iPad14,10": @"iPad Air (13-inch) (M2)", // wifi + @"iPad14,11": @"iPad Air (13-inch) (M2)", // cellular + + @"iPad16,3": @"iPad Pro (11-inch) (M4)", // wifi + @"iPad16,4": @"iPad Pro (11-inch) (M4)", // cellular + @"iPad16,5": @"iPad Pro (13-inch) (M4)", // wifi + @"iPad16,6": @"iPad Pro (13-inch) (M4)", // cellular }; NSString *value = codes[_machine]; diff --git a/Blink/Info.plist b/Blink/Info.plist index 9a7217bc6..5aa7fc1dc 100644 --- a/Blink/Info.plist +++ b/Blink/Info.plist @@ -80,6 +80,7 @@ APPL CFBundleShortVersionString $(MARKETING_VERSION) + 999 CFBundleSignature ???? CFBundleURLTypes @@ -112,6 +113,7 @@ blinkshell + 66 LSRequiresIPhoneOS LSSupportsOpeningDocumentsInPlace @@ -160,12 +162,6 @@ UISceneDelegateClassName Blink.SceneDelegate - - UISceneConfigurationName - whatsnew - UISceneDelegateClassName - Blink.WhatsNewSceneDelegate - UIWindowSceneSessionRoleExternalDisplayNonInteractive diff --git a/Blink/Intro.swift b/Blink/Intro.swift deleted file mode 100644 index 37c0bde73..000000000 --- a/Blink/Intro.swift +++ /dev/null @@ -1,1122 +0,0 @@ -////////////////////////////////////////////////////////////////////////////////// -// -// B L I N K -// -// Copyright (C) 2016-2019 Blink Mobile Shell Project -// -// This file is part of Blink. -// -// Blink is free software: you can redistribute it and/or modify -// it under the terms of the GNU General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// Blink is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with Blink. If not, see . -// -// In addition, Blink is also subject to certain additional terms under -// GNU GPL version 3 section 7. -// -// You should have received a copy of these additional terms immediately -// following the terms and conditions of the GNU General Public License -// which accompanied the Blink Source Code. If not, see -// . -// -//////////////////////////////////////////////////////////////////////////////// - -import SwiftUI - -enum BlinkColors { - static let bg = Color(red: 20.0 / 256.0, green: 30.0 / 256.0 , blue: 33.0 / 256.0) -// static let yellow = Color(red: 255.0 / 256.0, green: 184.0 / 256.0, blue: 0.0 / 256.0) - static let blink = Color(red: 10.0 / 256.0, green: 224.0 / 256.0, blue: 240.0 / 256.0) - static let build = Color(red: 116.0 / 256.0, green: 251.0 / 256.0, blue: 152.0 / 256.0) - static let code = Color(red: 255.0 / 256.0, green: 184.0 / 256.0, blue: 0.0 / 256.0) - - static let secondaryBtnBG = Color(red: 16.0 / 256.0, green: 40.0 / 256.0, blue: 41.0 / 256.0) - static let secondaryBtnText = Color(red: 10.0 / 256.0, green: 224.0 / 256.0, blue: 240.0 / 256.0) - static let secondaryBtnBorder = Color(red: 42.0 / 256.0, green: 80.0 / 256.0, blue: 83.0 / 256.0) - - static let primaryBtnBG = Color(red: 86.0 / 256.0, green: 62.0 / 256.0, blue: 0.0 / 256.0) - static let primaryBtnText = BlinkColors.code - static let primaryBtnBorder = Color(red: 168.0 / 256.0, green: 121.0 / 256.0, blue: 0.0 / 256.0) - - static let headerText = BlinkColors.code - static let infoText = Color(red: 195.0 / 256.0, green: 219.0 / 256.0, blue: 219.0 / 256.0) - - static let linearGradient1 = Color(red: 20.0 / 256.0, green: 33.0 / 256.0, blue: 33.0 / 256.0) -// static let linearGradient2 = Color(red: 9.0 / 256.0, green: 13.0 / 256.0, blue: 14.0 / 256.0) - static let linearGradient2 = Color(red: (10 + 9.0) / 256.0, green: (10 + 13.0) / 256.0, blue: (10 + 14.0) / 256.0) - - static let radialGradient1 = Color(red: 1.0 / 256.0, green: 4.0 / 256.0, blue: 4.0 / 256.0) - static let radialGradient2 = Color(red: 20.0 / 256.0, green: 33.0 / 256.0, blue: 33.0 / 256.0, opacity: 0) - - static let blinkBG = Color(red: 16.0 / 256.0, green: 40.0 / 256.0, blue: 41.0 / 256.0) - static let buildBG = Color(red: 24.0 / 256.0, green: 56.0 / 256.0, blue: 32.0 / 256.0) - static let codeBG = Color(red: 86.0 / 256.0, green: 62.0 / 256.0, blue: 0.0 / 256.0) - - static let blinkText = Color(red: 195.0 / 256.0, green: 219.0 / 256.0, blue: 219.0 / 256.0) - static let buildText = Color(red: 207.0 / 256.0, green: 241.0 / 256.0, blue: 216.0 / 256.0) - static let codeText = Color(red: 240.0 / 256.0, green: 221.0 / 256.0, blue: 171.0 / 256.0) - - static let termsText = Color(red: 92.0 / 256.0, green: 117.0 / 256.0, blue: 117.0 / 256.0) - - static let purchase = Color(red: 149.0 / 256.0, green: 104.0 / 256.0, blue: 203.0 / 256.0) - -// #5C7575 -} - -let BLINK_APP_FONT_NAME: String = Bundle.main.infoDictionary?["BLINK_APP_FONT"] as? String ?? "JetBrains Mono" - -public enum BlinkFonts { -// static let snippetIndex = Font.custom(BLINK_APP_FONT_NAME, size: 18, relativeTo: .body) -// static let snippetContent = Font.custom(BLINK_APP_FONT_NAME, size: 18, relativeTo: .body) - - static let snippetEditContent = UIFont(name: BLINK_APP_FONT_NAME, size: 18)! - - static let header = Font.custom(BLINK_APP_FONT_NAME, size: 34, relativeTo: .title) - static let headerCompact = Font.custom(BLINK_APP_FONT_NAME, size: 28, relativeTo: .title) - - static let info = Font.system(.title3) - static let infoCompact = Font.system(.body) - static let btn = Font.custom(BLINK_APP_FONT_NAME, size: 16, relativeTo: .body) - static let btnSub = Font.custom(BLINK_APP_FONT_NAME, size: 12, relativeTo: .body) - - static let bullet = Font.custom(BLINK_APP_FONT_NAME, size: 24, relativeTo: .body).weight(.bold) - static let bulletCompact = Font.custom(BLINK_APP_FONT_NAME, size: 18, relativeTo: .body).weight(.bold) - static let bulletText = Font.custom(BLINK_APP_FONT_NAME, size: 18, relativeTo: .body).weight(.bold) - static let bulletTextCompact = Font.custom(BLINK_APP_FONT_NAME, size: 14, relativeTo: .body).weight(.bold) - - static let offeringSubheader = Font.body.weight(.bold) - static let offeringCompactSubheader = Font.footnote.weight(.bold) - static let offeringInfo = Font.system(.body) - static let offeringInfoCompact = Font.footnote -} - -extension Shape { - func fill(_ fillStyle: Fill, strokeBorder strokeStyle: Stroke, lineWidth: Double = 1) -> some View { - self - .stroke(strokeStyle, lineWidth: lineWidth) - .background(self.fill(fillStyle)) - } -} - -struct BlinkButtonWithoutHoverStyle: ButtonStyle { - let textColor: Color - let bgColor: Color - let borderColor: Color - let disabled: Bool - let inProgress: Bool - let minWidth: CGFloat? - - func makeBody(configuration: Configuration) -> some View { - configuration.label - .multilineTextAlignment(.center) - .lineSpacing(5.0) - .font(BlinkFonts.btn) - .foregroundColor(inProgress ? bgColor : textColor) - - .padding(EdgeInsets(top: 16, leading: 28, bottom: 16, trailing: 28)) - .frame(minWidth: minWidth) - .background( - RoundedRectangle(cornerRadius: 6, style: .continuous) - .fill((configuration.isPressed) ? borderColor : bgColor, strokeBorder: borderColor) - - ) - .opacity((disabled && !inProgress) ? 0.5 : 1.0) - - .overlay(Group { - if inProgress { - ProgressView().tint(textColor) - } - }) - } - - static func secondary(disabled: Bool, inProgress: Bool, minWidth: CGFloat? = nil) -> Self { - Self( - textColor: BlinkColors.secondaryBtnText, - bgColor: BlinkColors.secondaryBtnBG, - borderColor: BlinkColors.secondaryBtnBorder, - disabled: disabled, - inProgress: inProgress, - minWidth: minWidth - ) - } - - static func primary(disabled: Bool, inProgress: Bool, minWidth: CGFloat? = nil) -> Self { - Self( - textColor: BlinkColors.primaryBtnText, - bgColor: BlinkColors.primaryBtnBG, - borderColor: BlinkColors.primaryBtnBorder, - disabled: disabled, - inProgress: inProgress, - minWidth: minWidth - ) - } - -} - -struct BlinkButtonStyle: ButtonStyle { - let textColor: Color - let bgColor: Color - let borderColor: Color - let disabled: Bool - let inProgress: Bool - let minWidth: CGFloat? - - func makeBody(configuration: Configuration) -> some View { - configuration.label - .multilineTextAlignment(.center) - .lineSpacing(5.0) - .font(BlinkFonts.btn) - .foregroundColor(inProgress ? bgColor : textColor) - - .padding(EdgeInsets(top: 16, leading: 28, bottom: 16, trailing: 28)) - .frame(minWidth: minWidth) - .background( - RoundedRectangle(cornerRadius: 6, style: .continuous) - .fill((configuration.isPressed) ? borderColor : bgColor, strokeBorder: borderColor) - - ) - .opacity((disabled && !inProgress) ? 0.5 : 1.0) - - .overlay(Group { - if inProgress { - ProgressView().tint(textColor) - } - }) - .hoverEffect(.lift) - } - - static func secondary(disabled: Bool, inProgress: Bool, minWidth: CGFloat? = nil) -> Self { - Self( - textColor: BlinkColors.secondaryBtnText, - bgColor: BlinkColors.secondaryBtnBG, - borderColor: BlinkColors.secondaryBtnBorder, - disabled: disabled, - inProgress: inProgress, - minWidth: minWidth - ) - } - - static func primary(disabled: Bool, inProgress: Bool, minWidth: CGFloat? = nil) -> Self { - Self( - textColor: BlinkColors.primaryBtnText, - bgColor: BlinkColors.primaryBtnBG, - borderColor: BlinkColors.primaryBtnBorder, - disabled: disabled, - inProgress: inProgress, - minWidth: minWidth - ) - } -} - -struct PageCtx { - - let classicsMode: Bool - let proxy: GeometryProxy - let dynamicTypeSize: DynamicTypeSize - var horizontalCompact: Bool = false - var verticalCompact: Bool = false - let portrait: Bool - let getStartedHandler: () -> () - let checkBlinkBuildHandler: () -> () - let checkBlinkPlusHandler: () -> () - let checkOfferingsHandler: () -> () - let urlHandler: (URL) -> () - - - func pagePadding() -> EdgeInsets { - let padding = EdgeInsets(top: 50, leading: 50, bottom: 50, trailing: 50) - if proxy.size.width < 500 || proxy.size.height < 600 { - return EdgeInsets(top: 20, leading: 10, bottom: 20, trailing: 10) - } - return padding - } - - func outterPadding() -> CGFloat? { - if proxy.size.width < 500 || proxy.size.height < 600 { - return 0 - } - return nil - } - - func pagingPadding() -> EdgeInsets { - let padding = EdgeInsets(top: 50, leading: 34, bottom: 50, trailing: 34) - if proxy.size.width < 500 { - return EdgeInsets(top: 50, leading: -12, bottom: 50, trailing: -12) - } - if proxy.size.width < 700 { - return EdgeInsets(top: 50, leading: 0, bottom: 50, trailing: 0) - } - return padding - } - - func headerFont() -> Font { - verticalCompact ? BlinkFonts.headerCompact : BlinkFonts.header - } - - func infoFont() -> Font { - (verticalCompact || horizontalCompact) ? BlinkFonts.infoCompact : BlinkFonts.info - } - - func offeringHeaderFont() -> Font { - verticalCompact ? BlinkFonts.headerCompact : BlinkFonts.header - } - - func offeringSubheaderFont() -> Font { - verticalCompact ? BlinkFonts.offeringCompactSubheader : BlinkFonts.offeringSubheader - } - - func offeringInfoFont() -> Font { - (verticalCompact || horizontalCompact) ? BlinkFonts.offeringInfoCompact : BlinkFonts.offeringInfo - } - - func bulletPadding() -> EdgeInsets { - if dynamicTypeSize.isAccessibilitySize { - return EdgeInsets(top: 2, leading: 2, bottom: 2, trailing: 2) - } - return horizontalCompact - ? EdgeInsets(top: 4, leading: 6, bottom: 4, trailing: 6) - : EdgeInsets(top: 6, leading: 12, bottom: 6, trailing: 12) - } - - func bulletFont() -> Font { - verticalCompact ? BlinkFonts.bulletCompact : BlinkFonts.bullet - } - - func bulletTextFont() -> Font { - verticalCompact ? BlinkFonts.bulletTextCompact : BlinkFonts.bulletText - } - - func pageMaxHeight() -> CGFloat { - if dynamicTypeSize <= .medium { - return 780 - } - - if dynamicTypeSize <= .large { - return 820 - } - - if dynamicTypeSize <= .xLarge { - return 900 - } - - if dynamicTypeSize <= .xxLarge { - return 1000 - } - - return 1200 - } - - init( - classicsMode: Bool, - proxy: GeometryProxy, dynamicTypeSize: DynamicTypeSize, urlHandler: @escaping (URL) -> (), - getStartedHandler: @escaping () -> (), - checkOfferingsHandler: @escaping () -> (), - checkBlinkBuildHandler: @escaping () -> (), - checkBlinkPlusHandler: @escaping () -> () - ) { - self.classicsMode = classicsMode - self.proxy = proxy - self.dynamicTypeSize = dynamicTypeSize - self.getStartedHandler = getStartedHandler - self.checkOfferingsHandler = checkOfferingsHandler - self.checkBlinkBuildHandler = checkBlinkBuildHandler - self.checkBlinkPlusHandler = checkBlinkPlusHandler - self.horizontalCompact = proxy.size.width < 400 - self.verticalCompact = proxy.size.height < 706 - self.portrait = proxy.size.width < proxy.size.height - self.urlHandler = urlHandler - } -} - -struct CallToActionButtons: View { - let ctx: PageCtx - let url: URL - let text: Text - - var body: some View { - HStack { - Button( - action: { ctx.urlHandler(url) }, - label: { text } - ) - .buttonStyle(BlinkButtonStyle.secondary(disabled: false, inProgress: false)) - Spacer().frame(width: 20) - - Button("GET STARTED") { - ctx.getStartedHandler() - }.buttonStyle(BlinkButtonStyle.primary(disabled: false, inProgress: false)) - } - .padding(.bottom, ctx.portrait ? 26 : 0) - } -} - -struct FreeUsersCallToActionButtons: View { - let ctx: PageCtx - let text: Text - - var body: some View { - HStack { - Button( - action: { - EntitlementsManager.shared.dismissPaywall() - }, - label: { text } - ) - .buttonStyle(BlinkButtonStyle.secondary(disabled: false, inProgress: false)) - Spacer().frame(width: 20) - - Button("GET STARTED") { - ctx.getStartedHandler() - }.buttonStyle(BlinkButtonStyle.primary(disabled: false, inProgress: false)) - } - .padding(.bottom, ctx.portrait ? 26 : 0) - } -} - -struct TermsButtons: View { - let ctx: PageCtx - @StateObject var _purchases = PurchasesUserModel.shared - @State var opacity: CGFloat = 0.5 - - var body: some View { - HStack { - Button("FAQ") { - ctx.urlHandler(URL(string: "https://docs.blink.sh/faq#pricing")!) - } - .foregroundColor(BlinkColors.termsText).font(BlinkFonts.btnSub) - - Text("•").foregroundColor(BlinkColors.termsText).font(BlinkFonts.btnSub) - - Button("TERMS") { - _purchases.openTermsOfUse() - } - .foregroundColor(BlinkColors.termsText).font(BlinkFonts.btnSub) - - Text("•").foregroundColor(BlinkColors.termsText).font(BlinkFonts.btnSub) - Button("COPY ID") { - UIPasteboard.general.string = _purchases.getUserID() - } - .foregroundColor(BlinkColors.termsText).font(BlinkFonts.btnSub) - - } - .padding(.bottom, ctx.portrait ? 26 : 0) - } -} - -struct TabViewControls: View { - @Binding var pageIndex: Int - let firstPageIndex: Int - let lastPageIndex: Int - - var body: some View { - HStack { - Button { - if self.pageIndex > self.firstPageIndex { - withAnimation { - self.pageIndex -= 1 - } - } - } label: { - Image(systemName: "chevron.compact.left").font(.title).foregroundColor(BlinkColors.code) - .padding() - } - .opacity(pageIndex == self.firstPageIndex ? 0.3 : 1.0).disabled(pageIndex == self.firstPageIndex) - .hoverEffect(.highlight) - .keyboardShortcut(.leftArrow) - Spacer() - Button { - if self.pageIndex < lastPageIndex { - withAnimation { - self.pageIndex += 1 - } - } - } label: { - Image(systemName: "chevron.compact.right").font(.title).foregroundColor(BlinkColors.code) - .padding() - } - .opacity(pageIndex == lastPageIndex ? 0.3 : 1.0).disabled(pageIndex == lastPageIndex) - .hoverEffect(.highlight) - .keyboardShortcut(.rightArrow) - - } - } -} - -struct PageInfo: Identifiable { - let title: String - let info: Text - let image: String - let verticalImage: String? - let imageMaxSize: CGSize - let url: URL - let linkText: Text - let compactInfo: Text - - var id: String { title } - - init(title: String, linkText: Text, url: URL, info: Text, compactInfo: Text, image: String, imageMaxSize: CGSize = CGSize(width: 700, height: 450)) { - self.title = title - self.linkText = linkText - self.url = url - self.info = info - self.compactInfo = compactInfo - self.image = image - self.verticalImage = nil - self.imageMaxSize = imageMaxSize - } - - init(title: String, linkText: Text, url: URL, info: Text, compactInfo: Text, image: String, verticalImage: String, imageMaxSize: CGSize = CGSize(width: 700, height: 450)) { - self.title = title - self.linkText = linkText - self.url = url - self.info = info - self.compactInfo = compactInfo - self.image = image - self.verticalImage = verticalImage - self.imageMaxSize = imageMaxSize - } - - // TODO Don't like having full fields hanging here. Maybe just "Strings" - static let multipleTerminalsInfo = PageInfo( - title: "MULTIPLE TERMINALS & WINDOWS", - linkText: Text("READ DOCS"), - url: URL(string: "https://docs.blink.sh/basics/navigation")!, - info: Text("Use **pinch** to zoom the terminal. Use **two finger tap** to create a new shell. Use **slide** to move between shells. Use **double tap Cmd or Home bar** for menu.\nType **help** if you need it."), - compactInfo: Text("Not to use in compact"), - image: "intro-windows" - - ) - - static let hostsKeysEverywhereInfo = PageInfo( - title: "YOUR HOSTS & KEYS, EVERYWHERE", - linkText: Text("READ DOCS"), - url: URL(string: "https://docs.blink.sh/basics/hosts")!, - info: Text("Type **`config`** to enter the configuration. Setup remote connections through Hosts and Keys. Add special keys and keyboard shortcuts. Customize the shell appearance through fonts and themes."), - compactInfo: Text("Not to use in compact"), - image: "intro-settings" - ) - - static let sshMoshToolsInfo = PageInfo( - title: "SSH, MOSH & BASIC TOOLS", - linkText: Text("\(Image(systemName: "play.rectangle.fill")) WATCH"), - url: URL(string: "https://youtube.com/shorts/VYmrSlG9lX0")!, - info: Text("Type **`mosh`** for high-performance remote shells. Type **`ssh`** for secure shells and tunnels. Type **`sftp`** or **`scp`** for secure file transfer. Have access to UNIX tools like **`cd`**, **`ls`**, **`ping`**, etc..."), - compactInfo: Text("SSH & Mosh • Secure Keys, Certificates & HW • Jump Hosts • Agent • SFTP"), - image: "intro-commands" - ) - - static let blinkCodeInfo = PageInfo( - title: "BLINK CODE, YOUR NEW SUPERPOWER", - linkText: Text("READ DOCS"), - url: URL(string: "https://docs.blink.sh/advanced/code")!, - info: Text("Use **`code`** for VS Code editor capabilities. Edit local files, remote files, and even connect to GitHub Codespaces, GitPod or others. All within a first class iOS experience adapted to your device."), - compactInfo: Text("Edit local files • Edit remote files • Interface adapted to your mobile device"), - image: "intro-code", - imageMaxSize: CGSize(width: 680, height: 400) - ) - - static let blinkBuildInfo = PageInfo( - title: "BUILD YOUR DEV ENVIRONMENTS", - linkText: Text("\(Image(systemName: "play.rectangle.fill")) WATCH"), - url: URL(string: "https://youtu.be/78XukJvz5vg")!, - info: Text("Use **`build`** to access instant dev environments for any task. Use our default Hacker Tools container for coding on Python, JS, Go, Rust, C and many other languages. Connect containers to run any application."), - compactInfo: Text("Run Python, Go, Rust, and others •\u{00a0}2\u{00a0}vCPU •\u{00a0}4\u{00a0}GB\u{00a0}RAM •\u{00a0}50\u{00a0}hours/month"), - image: "intro-build-horizontal", - verticalImage: "intro-build-vertical" - ) -} - -struct WalkthroughPageView: View { - let ctx: PageCtx - let info: PageInfo - - var body: some View { - VStack { - Text(info.title) - .font(ctx.headerFont()) - .foregroundColor(BlinkColors.headerText) - .multilineTextAlignment(.center) - Spacer() - Image(ctx.portrait ? info.verticalImage ?? info.image : info.image) - .resizable() - .scaledToFit() - .frame(maxWidth: info.imageMaxSize.width , maxHeight: info.imageMaxSize.height) - .padding() - Spacer() - info.info - .font(ctx.infoFont()) - .multilineTextAlignment(.center) - .foregroundColor(BlinkColors.infoText) - .frame(maxWidth: 810) - .padding(.bottom) - Spacer() - CallToActionButtons(ctx: ctx, url: info.url, text: info.linkText) - }.padding(ctx.pagePadding()) - } -} - -struct OfferingPageView: View { - let ctx: PageCtx - let info: PageInfo - - var body: some View { - VStack { - Spacer() - Image(info.image) - .resizable() - .scaledToFit() - .frame(maxWidth: info.imageMaxSize.width , maxHeight: info.imageMaxSize.height) - info.compactInfo - .font(ctx.offeringInfoFont()) - .foregroundColor(BlinkColors.infoText) - .multilineTextAlignment(.center) - .padding([.leading, .trailing]) - Spacer() - } - } -} - -struct ShellBulletView: View { - let ctx: PageCtx - let showList: Bool - - var body: some View { - VStack { - HStack(alignment: .firstTextBaseline) { - Text("SHELL") - .font(ctx.bulletFont()).foregroundColor(BlinkColors.blink) - .padding(ctx.bulletPadding()) - .background(RoundedRectangle(cornerRadius: 6.0).fill(BlinkColors.blinkBG)) - Text("into remote machines using SSH and Mosh.").font(ctx.bulletTextFont()).foregroundColor(BlinkColors.blink) - } - if showList { - Text("Use Secure Keys, Certificates & HW • Jump Hosts • Agent • SFTP ").font(Font.system(.callout)).foregroundColor(BlinkColors.blinkText).multilineTextAlignment(.center).padding([.leading, .trailing]) - } - } - } -} - -struct ShellClassicBulletView: View { - let ctx: PageCtx - let showList: Bool - - var body: some View { - VStack { - HStack(alignment: .firstTextBaseline) { - Text("SHELL") - .font(ctx.bulletFont()).foregroundColor(BlinkColors.blink) - .padding(ctx.bulletPadding()) - .background(RoundedRectangle(cornerRadius: 6.0).fill(BlinkColors.blinkBG)) - Text("into remote machines using SSH and Mosh.").font(ctx.bulletTextFont()).foregroundColor(BlinkColors.blink) - } - if showList { - Text("Classic functionality • Secure keys • Jump Hosts • Agent • SFTP ").font(Font.system(.callout)).foregroundColor(BlinkColors.blinkText).multilineTextAlignment(.center).padding([.leading, .trailing]) - } - } - } -} - - -struct BuildBulletView: View { - let ctx: PageCtx - let showList: Bool - - var body: some View { - VStack { - HStack(alignment: .firstTextBaseline) { - Text("BUILD") - .font(ctx.bulletFont()).foregroundColor(BlinkColors.build) - .padding(ctx.bulletPadding()) - .background(RoundedRectangle(cornerRadius: 6.0).fill(BlinkColors.buildBG)) - Text("environments for any dev task, in seconds.").font(ctx.bulletTextFont()).foregroundColor(BlinkColors.build) - } - if showList { - Text("Run Python, Go, Rust, and others •\u{00a0}2\u{00a0}vCPU •\u{00a0}4\u{00a0}GB\u{00a0}RAM •\u{00a0}50\u{00a0}hours/month") - .font(Font.system(.callout)).foregroundColor(BlinkColors.buildText) - .multilineTextAlignment(.center) - .padding([.leading, .trailing]) - } - } - } -} - -struct CodeBulletView: View { - let ctx: PageCtx - let showList: Bool - - var body: some View { - VStack { - HStack(alignment: .firstTextBaseline) { - Text("CODE") - .font(ctx.bulletFont()).foregroundColor(BlinkColors.code) - .padding(ctx.bulletPadding()) - .background(RoundedRectangle(cornerRadius: 6.0).fill(BlinkColors.codeBG)) - Text("using the world’s most popular editor.").font(ctx.bulletTextFont()).foregroundColor(BlinkColors.code) - } - if showList { - Text("Edit local files • Edit remote files • Interface adapted to your mobile device") - .font(Font.system(.callout)) - .foregroundColor(BlinkColors.codeText) - .multilineTextAlignment(.center) - .padding([.leading, .trailing]) - } - } - } -} - -struct OfferingsPresentationView: View { - let ctx: PageCtx - @StateObject var _purchases = PurchasesUserModel.shared - - var body: some View { - VStack { - Text("WELCOME TO BLINK") - .font(ctx.headerFont()) - .foregroundColor(BlinkColors.headerText) - .multilineTextAlignment(.center) - - VStack(alignment: .center, spacing: 20) { - Spacer() - ShellBulletView(ctx: ctx, showList: false) - BuildBulletView(ctx: ctx, showList: false) - CodeBulletView(ctx: ctx, showList: false) - Spacer() - } - - Spacer() - Text("Thanks for downloading. Your device is small, but with Blink, it can take on Big Jobs.\n" + - "Try free for 1 week, and let's get to work!") - .font(ctx.infoFont()) - .multilineTextAlignment(.center) - .foregroundColor(BlinkColors.infoText) - .frame(maxWidth: 810) - .padding(.bottom) - Spacer() - HStack { - Button( - action: ctx.checkOfferingsHandler, - label: { Text("CONTINUE") } - ) - .buttonStyle(BlinkButtonStyle.primary(disabled: false, inProgress: false)) - - Button( - action: _purchases.restorePurchases, - label: { Text("RESTORE PURCHASES") } - ) - .buttonStyle(BlinkButtonStyle.secondary(disabled: _purchases.restoreInProgress || _purchases.purchaseInProgress, - inProgress: _purchases.restoreInProgress)) - } - .padding(.bottom, ctx.portrait ? 26 : 0) - }.padding(ctx.pagePadding()) - } -} - -let minButtonWidth: CGFloat = 252 - -struct OfferingsView: View { - let ctx: PageCtx - @StateObject var _purchases = PurchasesUserModel.shared - let blinkPlusBuildPages = [PageInfo.blinkBuildInfo, PageInfo.sshMoshToolsInfo, PageInfo.blinkCodeInfo] - let blinkPlusPages = [PageInfo.sshMoshToolsInfo, PageInfo.blinkCodeInfo] - - @State var presentBlinkPlus = false - @State var blinkPlusBuildPageIndex = 0 - @State var blinkPlusPageIndex = 0 - @State var trialNotification = true - - var body: some View { - VStack { - VStack { - VStack(alignment: .leading) { - Text("BLINK+BUILD") - .font(ctx.offeringHeaderFont()) - .foregroundColor(BlinkColors.headerText) - .multilineTextAlignment(.leading) - Text("The full toolbox. Everything on Blink+ and on-demand dev environments.") - .font(ctx.offeringSubheaderFont()) - .foregroundColor(BlinkColors.headerText) - .multilineTextAlignment(.leading) - }.frame(maxWidth: .infinity, alignment: .leading) - .padding([.top, .leading, .trailing]) - - if presentBlinkPlus { - let pricePerMonth = _purchases.formattedBlinkPlusBuildPriceWithPeriod()?.uppercased() ?? "" - Button("COMPARE (\(pricePerMonth))") { - withAnimation { - self.presentBlinkPlus = false - } - } - .buttonStyle(BlinkButtonWithoutHoverStyle.primary(disabled: _purchases.restoreInProgress || _purchases.purchaseInProgress, inProgress: false, minWidth: minButtonWidth)) - .disabled(_purchases.restoreInProgress || _purchases.purchaseInProgress) - .padding() - } else { - VStack { - Spacer() - TabView(selection: $blinkPlusBuildPageIndex) { - ForEach(Array(zip(blinkPlusBuildPages.indices, blinkPlusBuildPages)), id: \.0) { index, info in - OfferingPageView(ctx: ctx, info: info).tag(index) - } - } - .tabViewStyle(.page(indexDisplayMode: .never)) - .overlay(TabViewControls(pageIndex: $blinkPlusBuildPageIndex, - firstPageIndex: 0, - lastPageIndex: blinkPlusBuildPages.count - 1)) - - Spacer() - } - - VStack { - Button(blinkPlusBuildSubscribeButtonText()) { - Task { - await _purchases.purchaseBlinkPlusBuildWithValidation(setupTrial: trialNotification) - } - }.buttonStyle(BlinkButtonStyle.primary(disabled: _purchases.restoreInProgress || _purchases.purchaseInProgress, - inProgress: _purchases.purchaseInProgress || _purchases.formattedBlinkPlusBuildPriceWithPeriod() == nil, minWidth: minButtonWidth)) - .alert("Info", isPresented: $_purchases.restoredPurchaseMessageVisible) { - Button("OK") { - EntitlementsManager.shared.dismissPaywall() - } - } message: { - Text(_purchases.restoredPurchaseMessage) - } - if _purchases.blinkPlusBuildTrialAvailable() { - HStack { - Spacer() - Text("Get Trial Reminder") - .foregroundColor(BlinkColors.infoText) - .font(BlinkFonts.btnSub) - Toggle("", isOn: $trialNotification) - .toggleStyle(.switch) - .labelsHidden() - .scaleEffect(0.7) - .tint(BlinkColors.primaryBtnBorder) - .disabled(_purchases.restoreInProgress || _purchases.purchaseInProgress) - Spacer() - }.controlSize(.mini) - } - }.padding(.bottom) - } - }.overlay( - RoundedRectangle(cornerRadius: 16) - .stroke(BlinkColors.headerText, lineWidth: 2) - ) - - VStack { - VStack(alignment: .leading) { - Text("BLINK+") - .font(ctx.offeringHeaderFont()) - .foregroundColor(BlinkColors.blink) - .multilineTextAlignment(.leading) - Text("The shell of choice for thousands of developers. SSH, Mosh, Code, fast and customizable, etc.") - .font(ctx.offeringSubheaderFont()) - .foregroundColor(BlinkColors.blink) - .multilineTextAlignment(.leading) - }.frame(maxWidth: .infinity, alignment: .leading) - .padding([.top, .leading, .trailing]) - - if presentBlinkPlus { - VStack { - Spacer() - TabView(selection: $blinkPlusPageIndex) { - ForEach(Array(zip(blinkPlusPages.indices, blinkPlusPages)), id: \.0) { index, info in - OfferingPageView(ctx: ctx, info: info).tag(index) - } - } - .tabViewStyle(.page(indexDisplayMode: .never)) - .overlay(TabViewControls(pageIndex: $blinkPlusPageIndex, - firstPageIndex: 0, - lastPageIndex: blinkPlusPages.count - 1)) - Spacer() - } - - Button(blinkPlusSubscribeButtonText()) { - Task { - await _purchases.purchaseBlinkShellPlusWithValidation() - } - }.buttonStyle(BlinkButtonStyle.secondary(disabled: _purchases.restoreInProgress || _purchases.purchaseInProgress, - inProgress: _purchases.purchaseInProgress || _purchases.formattedBlinkPlusBuildPriceWithPeriod() == nil, minWidth: minButtonWidth)) - .padding() - .alert("Info", isPresented: $_purchases.restoredPurchaseMessageVisible) { - Button("OK") { - EntitlementsManager.shared.dismissPaywall() - } - } message: { - Text(_purchases.restoredPurchaseMessage) - } - } else { - let pricePerMonth = _purchases.blinkShellPlusProduct?.priceFormatter?.string(from: _purchases.blinkPlusProduct?.pricePerMonth ?? 0.0) ?? "" - Button("COMPARE (\(pricePerMonth)/MONTH)") { - withAnimation { - self.presentBlinkPlus = true - } - } - .buttonStyle(BlinkButtonWithoutHoverStyle.secondary(disabled: _purchases.restoreInProgress || _purchases.purchaseInProgress, inProgress: false, minWidth: minButtonWidth)) - .disabled(_purchases.restoreInProgress || _purchases.purchaseInProgress) - .padding() - } - }.overlay( - RoundedRectangle(cornerRadius: 16) - .stroke(BlinkColors.blink, lineWidth: 2) - ).padding(.bottom) - - if !ctx.classicsMode { - TermsButtons(ctx: ctx) - } - } - } - - func blinkPlusBuildSubscribeButtonText() -> String { - let price = _purchases.formattedBlinkPlusBuildPriceWithPeriod()?.uppercased() ?? ""; - - if _purchases.blinkPlusBuildTrialAvailable() { - return "DO 1 WEEK FREE, THEN \(price)" - } else { - return "BUY \(price)" - } - } - - func blinkPlusSubscribeButtonText() -> String { - let price = _purchases.formattedPlusPriceWithPeriod()?.uppercased() ?? "" - let formattedPricePerMonth = _purchases.blinkShellPlusProduct?.priceFormatter?.string(from: _purchases.blinkPlusProduct?.pricePerMonth ?? 0.0) ?? "" - - return "BUY \(price) (JUST \(formattedPricePerMonth)/MONTH!)" - } -} - - -struct OfferView: View { - let ctx: PageCtx - let page: OfferingsPageState - let startWithBlinkPlus: Bool - - var body: some View { - VStack { - switch page { - case .presentation: OfferingsPresentationView(ctx: ctx).transition(.move(edge: .leading)) - case .offerings: OfferingsView(ctx: ctx, presentBlinkPlus: startWithBlinkPlus).transition(.move(edge: .trailing))//.transition(.opacity) - } - } - .padding(ctx.pagePadding()) - } -} - -struct OfferForFreeAndClassicsView: View { - - @Environment(\.dynamicTypeSize) var dynamicTypeSize - @State var offerPage = OfferingsPageState.offerings - @StateObject var _purchases = PurchasesUserModel.shared - - var body: some View { - GeometryReader { proxy in - - let ctx = PageCtx( - classicsMode: true, - proxy: proxy, - dynamicTypeSize: dynamicTypeSize, - urlHandler: {_ in}, - getStartedHandler: { }, - checkOfferingsHandler: { }, - checkBlinkBuildHandler: { - withAnimation { - offerPage = .offerings - } - }, - checkBlinkPlusHandler: { - withAnimation { - offerPage = .offerings - } - } - ) - OfferView(ctx: ctx, page: offerPage, startWithBlinkPlus: true) - .frame(width: proxy.size.width, height: proxy.size.height) - .alert(errorMessage: $_purchases.alertErrorMessage) - .padding(.top, 50) - .ignoresSafeArea() - .background( - Rectangle() - .fill( - BlinkColors.bg - ).overlay( - Rectangle() - .fill( - LinearGradient(colors: [BlinkColors.linearGradient1, BlinkColors.linearGradient2], startPoint: UnitPoint(x: 0.5, y: 0.0), endPoint: UnitPoint(x:0.5, y:1.0)) - ) - ) - .overlay( - Rectangle() - .fill( - RadialGradient(colors: [BlinkColors.radialGradient1, BlinkColors.radialGradient2], center: UnitPoint(x: 0.5, y: 0.5), startRadius: 0, endRadius:max(proxy.size.width, proxy.size.height)) - ).opacity(0.4) - ).ignoresSafeArea(.all) - ) - } - - } -} - -struct InitialOfferingView: View { - let urlHandler: (URL) -> Void - - @Environment(\.dynamicTypeSize) var dynamicTypeSize - @State var offerPage = OfferingsPageState.presentation - @StateObject var _purchases = PurchasesUserModel.shared - - var body: some View { - GeometryReader { proxy in - - let ctx = PageCtx( - classicsMode: false, - proxy: proxy, - dynamicTypeSize: dynamicTypeSize, - urlHandler: urlHandler, - getStartedHandler: { }, - checkOfferingsHandler: { - withAnimation { - offerPage = .offerings - } - }, - checkBlinkBuildHandler: { - withAnimation { - offerPage = .offerings - } - }, - checkBlinkPlusHandler: { - withAnimation { - offerPage = .offerings - } - } - ) - - OfferView(ctx: ctx, page: offerPage, startWithBlinkPlus: false) - .frame(maxWidth: 986, maxHeight: ctx.pageMaxHeight()) - - .padding(.all, ctx.outterPadding()) - .frame(width: proxy.size.width, height: proxy.size.height) - .alert(errorMessage: $_purchases.alertErrorMessage) - -// .overlay(Text("\(proxy.size.width)x\(proxy.size.height)").foregroundColor(.blue)) - } - - } -} - -struct InitialOfferingWindow: View { - let urlHandler: (URL) -> Void - - var body: some View { - InitialOfferingView(urlHandler: urlHandler) - .background( - BlinkColors.bg.overlay( - LinearGradient(colors: - [BlinkColors.linearGradient1, BlinkColors.linearGradient2], - startPoint: UnitPoint(x: 0.5, y: 0.0), endPoint: UnitPoint(x:0.5, y:1.0)) - ) - .overlay( - RadialGradient(colors: [BlinkColors.radialGradient1, BlinkColors.radialGradient2], - center: UnitPoint(x: 0.5, y: 0.5), startRadius: 0, endRadius:1) - .opacity(0.4) - ) - ) - .ignoresSafeArea(.all, edges: [.bottom, .horizontal]) - } -} - -enum OfferingsPageState { - case presentation - case offerings -} - -struct WalkthroughView: View { - - let urlHandler: (URL) -> Void - let dismissHandler: () -> Void - - @Environment(\.dynamicTypeSize) var dynamicTypeSize - let pages: [PageInfo] = [ - PageInfo.multipleTerminalsInfo, - PageInfo.hostsKeysEverywhereInfo, - PageInfo.sshMoshToolsInfo, - PageInfo.blinkCodeInfo, - PageInfo.blinkBuildInfo - ] - - @StateObject var _purchases = PurchasesUserModel.shared - @StateObject var _entitlements = EntitlementsManager.shared - - @State var offerPage = OfferingsPageState.offerings - - @State var pageIndex = 0 - - init(urlHandler: @escaping (URL) -> Void, dismissHandler: @escaping () -> Void) { - self.urlHandler = urlHandler - self.dismissHandler = dismissHandler - } - - var body: some View { - GeometryReader { proxy in - - let ctx = PageCtx( - classicsMode: false, - proxy: proxy, - dynamicTypeSize: dynamicTypeSize, - urlHandler: urlHandler, - getStartedHandler: dismissHandler, - checkOfferingsHandler: { }, - checkBlinkBuildHandler: { }, - checkBlinkPlusHandler: { } - ) - - TabView(selection: $pageIndex) { - ForEach(Array(zip(pages.indices, pages)), id: \.0) { index, info in - WalkthroughPageView(ctx: ctx, info: info).tag(index) - } - } - .tabViewStyle(.page(indexDisplayMode: ctx.portrait ? .always : .never)) - .overlay( - HStack { - if !ctx.portrait { - TabViewControls(pageIndex: $pageIndex, firstPageIndex: 0, lastPageIndex: pages.count - 1) - } - } - ) - .frame(maxWidth: 986, maxHeight: ctx.pageMaxHeight()) - .background( - RoundedRectangle(cornerRadius: 21.67, style: .continuous) - .fill( - BlinkColors.bg - ).overlay( - RoundedRectangle(cornerRadius: 21.67, style: .continuous) - .fill( - LinearGradient(colors: [BlinkColors.linearGradient1, BlinkColors.linearGradient2], startPoint: UnitPoint(x: 0.5, y: 0.0), endPoint: UnitPoint(x:0.5, y:1.0)) - ) - ) - .overlay( - RoundedRectangle(cornerRadius: 21.67, style: .continuous) - .fill( - RadialGradient(colors: [BlinkColors.radialGradient1, BlinkColors.radialGradient2], center: UnitPoint(x: 0.5, y: 0.5), startRadius: 0, endRadius:max(proxy.size.width, proxy.size.height)) - ).opacity(0.4) - ) - ) - .padding(.all, ctx.outterPadding()) - .frame(width: proxy.size.width, height: proxy.size.height) - .alert(errorMessage: $_purchases.alertErrorMessage) - } - } -} - -struct WalkthroughWindow: View { - let urlHandler: (URL) -> Void - let dismissHandler: () -> Void - var body: some View { - WalkthroughView(urlHandler: urlHandler, dismissHandler: dismissHandler) - .background(Color.black, ignoresSafeAreaEdges: .all) - } -} diff --git a/Blink/KBTracker.swift b/Blink/KBTracker.swift index 104bfc567..34ccfce73 100644 --- a/Blink/KBTracker.swift +++ b/Blink/KBTracker.swift @@ -139,7 +139,8 @@ class KBObserver: NSObject, UIInteraction { } class KBTracker: NSObject { - private(set) var hideSmartKeysWithHKB = !BKUserConfigurationManager.userSettingsValue(forKey: BKUserConfigShowSmartKeysWithXKeyBoard) + private(set) var hideSmartKeysWithHKB = true + //private(set) var hideSmartKeysWithHKB = !BKUserConfigurationManager.userSettingsValue(forKey: BKUserConfigShowSmartKeysWithXKeyBoard) @objc static let shared = KBTracker() @@ -229,8 +230,8 @@ class KBTracker: NSObject { } @objc private func _updateSettings() { - KBSound.isMutted = BKUserConfigurationManager.userSettingsValue(forKey: BKUserConfigMuteSmartKeysPlaySound) - hideSmartKeysWithHKB = !BKUserConfigurationManager.userSettingsValue(forKey: BKUserConfigShowSmartKeysWithXKeyBoard) + hideSmartKeysWithHKB = true + //hideSmartKeysWithHKB = !BKUserConfigurationManager.userSettingsValue(forKey: BKUserConfigShowSmartKeysWithXKeyBoard) input?.sync(traits: kbTraits, device: kbDevice, hideSmartKeysWithHKB: hideSmartKeysWithHKB) } diff --git a/Blink/LayoutManager.m b/Blink/LayoutManager.m index 60f30e395..9814a2af8 100644 --- a/Blink/LayoutManager.m +++ b/Blink/LayoutManager.m @@ -95,8 +95,13 @@ + (UIEdgeInsets) buildSafeInsetsForController:(UIViewController *)ctrl andMode:( result = deviceMargins; if (DeviceInfo.shared.hasCorners && UIDevice.currentDevice.userInterfaceIdiom == UIUserInterfaceIdiomPad) { - result.top = 16; - result.bottom = 16; + if ([DeviceInfo.shared.marketingName containsString:@"M4"]) { + result.top = 25; + result.bottom = 25; + } else { + result.top = 16; + result.bottom = 16; + } } break; @@ -108,10 +113,17 @@ + (UIEdgeInsets) buildSafeInsetsForController:(UIViewController *)ctrl andMode:( } if (!deviceInfo.hasNotch) { - result.top = 5; - result.left = 5; - result.right = MAX(deviceMargins.right, 5); - result.bottom = fullScreen ? 5 : 10; + if ([DeviceInfo.shared.marketingName containsString:@"M4"]) { + result.top = 8; + result.left = 8; + result.right = MAX(deviceMargins.right, 8); + result.bottom = fullScreen ? 8 : 10; + } else { + result.top = 5; + result.left = 5; + result.right = MAX(deviceMargins.right, 5); + result.bottom = fullScreen ? 5 : 10; + } break; } diff --git a/Blink/Migrator/1810Migration.swift b/Blink/Migrator/1810Migration.swift new file mode 100644 index 000000000..9b404f7ba --- /dev/null +++ b/Blink/Migrator/1810Migration.swift @@ -0,0 +1,85 @@ +////////////////////////////////////////////////////////////////////////////////// +// +// B L I N K +// +// Copyright (C) 2016-2024 Blink Mobile Shell Project +// +// This file is part of Blink. +// +// Blink is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Blink is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Blink. If not, see . +// +// In addition, Blink is also subject to certain additional terms under +// GNU GPL version 3 section 7. +// +// You should have received a copy of these additional terms immediately +// following the terms and conditions of the GNU General Public License +// which accompanied the Blink Source Code. If not, see +// . +// +//////////////////////////////////////////////////////////////////////////////// + + +import Foundation +import CoreData + + +class MigrationFileProviderReplicatedExtension: MigrationStep { + var version: Int { get { 1810 } } + + func execute() throws { + // Replace old FileProvider extension items with the new FileProvider Replicated Extension + for host in BKHosts.allHosts() { + guard let json = host.fpDomainsJSON, !json.isEmpty + else { + continue + } + + let domains = FileProviderDomain.listFrom(jsonString: json) + if domains.count > 0 { + domains.forEach { domain in + if !domain.useReplicatedExtension { + domain.useReplicatedExtension = true + } + } + host.fpDomainsJSON = FileProviderDomain.toJson(list: domains) + + BKHosts._replaceHost(host) + } + } + + // Do we need it or does it happen on its own? + BKiCloudSyncHandler.shared()?.check(forReachabilityAndSync: nil) + + self.deleteFileProviderStorage() + } + + private func deleteFileProviderStorage() { + // Clean up the old File Provider path + let fileProviderURL = NSFileProviderManager.default.documentStorageURL + + guard let contentURLs = try? FileManager.default.contentsOfDirectory(at: fileProviderURL, includingPropertiesForKeys: nil, options: []) else { + print("No contents found at \(fileProviderURL.path)") + return + } + + for url in contentURLs { + do { + try FileManager.default.removeItem(at: url) + print("Removed: \(url.path)") + } catch { + print("Failed to remove \(url.path): \(error)") + } + } + } +} diff --git a/Blink/Migrator/Migrator.swift b/Blink/Migrator/Migrator.swift index 1c132ab85..c90acf104 100644 --- a/Blink/Migrator/Migrator.swift +++ b/Blink/Migrator/Migrator.swift @@ -35,20 +35,23 @@ import Foundation @objc class Migrator : NSObject { @objc static func perform() { - Self.perform(steps: [MigrationToAppGroup(), MigrationAddSnippetsShortcut()]) + Self.perform(steps: [MigrationToAppGroup(), + MigrationAddSnippetsShortcut(), + MigrationFileProviderReplicatedExtension() + ]) } - + static func perform(steps: [MigrationStep]) { let migratorFileURL = URL(fileURLWithPath: BlinkPaths.groupContainerPath()).appendingPathComponent(".migrator") - + let currentVersionString = try? String(contentsOf: migratorFileURL, encoding: .utf8) var currentVersion = Int(currentVersionString ?? "0") ?? 0 - + steps.forEach { step in guard step.version > currentVersion else { return } - + do { try step.execute() currentVersion = step.version diff --git a/Blink/NonStdIO/NonStdIO.swift b/Blink/NonStdIO/NonStdIO.swift index c4af62b08..bb3a69f6a 100644 --- a/Blink/NonStdIO/NonStdIO.swift +++ b/Blink/NonStdIO/NonStdIO.swift @@ -65,10 +65,10 @@ public class NonStdIO: Codable { public var verbose: Bool = false public var quiet: Bool = false - public init() { - self.in_ = InputStream.stdin - self.out = OutputStream.stdout - self.err = OutputStream.stderr + public init(in_: InputStream? = nil, out: OutputStream? = nil, err: OutputStream? = nil) { + self.in_ = in_ ?? InputStream.stdin + self.out = out ?? OutputStream.stdout + self.err = err ?? OutputStream.stderr } public required init(from decoder: Decoder) throws { @@ -80,7 +80,7 @@ public class NonStdIO: Codable { public func encode(to encoder: Encoder) throws { } - public static let standart = NonStdIO() + public static let standard = NonStdIO() } public protocol WithNonStdIO { diff --git a/Blink/SceneDelegate.swift b/Blink/SceneDelegate.swift index c82c5698d..bd39491fe 100644 --- a/Blink/SceneDelegate.swift +++ b/Blink/SceneDelegate.swift @@ -45,7 +45,7 @@ class ExternalWindow: UIWindow { @objc class ShadowWindow: UIWindow { private var _refWindow: UIWindow private let _spCtrl: SpaceController - + var spaceController: SpaceController { _spCtrl } @objc var refWindow: UIWindow { get { @@ -55,28 +55,28 @@ class ExternalWindow: UIWindow { _refWindow = newValue } } - + init(windowScene: UIWindowScene, refWindow: UIWindow, spCtrl: SpaceController) { _refWindow = refWindow _spCtrl = spCtrl - + super.init(windowScene: windowScene) - + frame = _refWindow.frame rootViewController = _spCtrl self.clipsToBounds = false } - + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - + override var frame: CGRect { get { _refWindow.frame } set { super.frame = _refWindow.frame } } - - + + @objc static var shared: ShadowWindow? = nil } @@ -92,96 +92,57 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { private var _lockCtrl: UIViewController? = nil private var _spCtrl = SpaceController() private var paywallWindow: UIWindow? = nil - - override init() { - super.init() - - let nc = NotificationCenter.default - nc.addObserver(self, selector: #selector(_showPaywallIfNeeded), name: .subscriptionNag, object: nil) - } - - deinit { - NotificationCenter.default.removeObserver(self) - } - + public func showingPaywall() -> Bool { self.paywallWindow != nil } - + override var editingInteractionConfiguration: UIEditingInteractionConfiguration { super.editingInteractionConfiguration } - + @objc private func _showPaywallIfNeeded() { -// if FeatureFlags.checkReceipt { -// return -// } - let entitlements = EntitlementsManager.shared - let doShowPaywall = entitlements.doShowPaywall() - - // we are showing plans for keys - if entitlements.navigationSteps.contains(.plans) { - if !doShowPaywall { - entitlements.navigationSteps = [] - } - return - } - - // we are showing offer in settings - if let navCtrl = entitlements.navigationCtrl { - if !doShowPaywall { - navCtrl.popToRootViewController(animated: true) - } - entitlements.navigationCtrl = nil - - return - } - - guard doShowPaywall - else { - if let window = self.paywallWindow { - if window.rootViewController?.presentedViewController != nil { - // We are showing migration view. It will close itself - return - } - UIView.animate(withDuration: 0.5) { - window.layer.opacity = 0; - } completion: { _ in - self.paywallWindow = nil - UIApplication.shared.sendAction(Selector("showWalkthroughAction"), to: self._spCtrl, from: nil, for: nil) - } - } - + + let doShowPaywall = !entitlements.hasActiveSubscriptions() + + guard doShowPaywall else { return } - + guard let windowScene = self.window?.windowScene else { return } - + _ = KBTracker.shared.input?.resignFirstResponder() - + guard self.paywallWindow == nil else { return } - - self.paywallWindow = UIWindow(windowScene: windowScene) - self.paywallWindow?.windowLevel = .statusBar + 0.5 - UIPageControl.appearance().currentPageIndicatorTintColor = UIColor.blinkTint - let view = InitialOfferingWindow(urlHandler: blink_openurl) + + let paywallWindow = UIWindow(windowScene: windowScene) + paywallWindow.windowLevel = .statusBar + 0.5 + self.paywallWindow = paywallWindow + + let view = NewIntroPageWindow(urlHandler: blink_openurl, + dismissHandler: { + UIView.animate(withDuration: 0.5) { + paywallWindow.layer.opacity = 0; + } completion: { _ in + self.paywallWindow = nil + } + }) let ctrl = StatusBarLessViewController(rootView: view) ctrl.lockPortrait = UIDevice.current.userInterfaceIdiom == .phone - self.paywallWindow?.rootViewController = ctrl - self.paywallWindow?.makeKeyAndVisible() - self.paywallWindow?.layer.opacity = 0; + paywallWindow.rootViewController = ctrl + paywallWindow.makeKeyAndVisible() + paywallWindow.layer.opacity = 0; UIView.animate(withDuration: 0.3) { - self.paywallWindow?.layer.opacity = 1; + paywallWindow.layer.opacity = 1; } - } - + func sceneDidDisconnect(_ scene: UIScene) { if scene == ShadowWindow.shared?.refWindow.windowScene { ShadowWindow.shared?.layer.removeFromSuperlayer() @@ -192,7 +153,7 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { ShadowWindow.shared?.windowScene = UIApplication.shared.connectedScenes.activeAppScene(exclude: scene) } } - + /** Handles the `ssh://` URL schemes and x-callback-url for devices that are running iOS 13 or higher. */ @@ -208,49 +169,49 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { _handleCodeUrlScheme(with: httpScheme) } } - + func scene( _ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { _ = KBTracker.shared - + guard let windowScene = scene as? UIWindowScene else { return } - + defer { self._showPaywallIfNeeded() } - + #if targetEnvironment(macCatalyst) if let titlebar = windowScene.titlebar { titlebar.titleVisibility = .hidden titlebar.autoHidesToolbarInFullScreen = true } #endif - + let conditions = scene.activationConditions - + conditions.canActivateForTargetContentIdentifierPredicate = NSPredicate(value: true) conditions.prefersToActivateForTargetContentIdentifierPredicate = NSPredicate(format: "SELF == 'blink://open-scene/\(scene.session.persistentIdentifier)'") - + _spCtrl.sceneRole = session.role _spCtrl.restoreWith(stateRestorationActivity: session.stateRestorationActivity) - + if session.role == .windowExternalDisplayNonInteractive, let mainScene = UIApplication.shared.connectedScenes.activeAppScene() { - + if BLKDefaults.overscanCompensation() == .BKBKOverscanCompensationMirror { return } - + let window = ExternalWindow(windowScene: windowScene) self.window = window - + let shadowWin: ShadowWindow - + if let win = ShadowWindow.shared { win.refWindow = window _spCtrl = win.spaceController @@ -259,34 +220,34 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { shadowWin = ShadowWindow(windowScene: mainScene, refWindow: window, spCtrl: _spCtrl) ShadowWindow.shared = shadowWin } - + window.shadowWindow = shadowWin - + shadowWin.makeKeyAndVisible() - + window.rootViewController = UIViewController() window.layer.addSublayer(shadowWin.layer) - + // window.makeKeyAndVisible() window.isHidden = false shadowWin.windowLevel = .init(rawValue: UIWindow.Level.normal.rawValue - 1) - + return } - + let window = UIWindow(windowScene: windowScene) self.window = window - + window.rootViewController = _spCtrl window.isHidden = false - + // Await until scene and streams are ready // NOTE We could also store the contexts and use them later. DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { self.scene(scene, openURLContexts: connectionOptions.urlContexts) } } - + private func _lockNonInteractiveScreenIfNeeded() { guard let window = ShadowWindow.shared?.refWindow, @@ -294,63 +255,63 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { else { return } - + if LocalAuth.shared.lockRequired { if let lockCtrl = sceneDelegate._lockCtrl { if window.rootViewController != lockCtrl { window.rootViewController = lockCtrl } - + return } - - + + sceneDelegate._lockCtrl = UIHostingController(rootView: LockView(unlockAction: nil)) window.rootViewController = _lockCtrl return } - - + + if window.rootViewController == _lockCtrl { window.rootViewController = UIViewController() } _lockCtrl = nil - + if let shadowWin = ShadowWindow.shared { window.layer.addSublayer(shadowWin.layer) } } - + func sceneDidBecomeActive(_ scene: UIScene) { guard let window = window else { return } - + // 0. Local Auth AutoLock Check on old screens _lockNonInteractiveScreenIfNeeded() - - + + if window == ShadowWindow.shared?.refWindow { return } - - + + // 1. Local Auth AutoLock Check - + if LocalAuth.shared.lockRequired { if let lockCtrl = _lockCtrl { if window.rootViewController != lockCtrl { window.rootViewController = lockCtrl } - + return } - + let unlockAction = scene.session.role == .windowApplication ? LocalAuth.shared.unlock : nil - + _lockCtrl = UIHostingController(rootView: LockView(unlockAction: unlockAction)) window.rootViewController = _lockCtrl - + unlockAction?() return @@ -358,51 +319,51 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { _lockCtrl = nil LocalAuth.shared.stopTrackTime() - + if let shadowWindow = ShadowWindow.shared, shadowWindow.windowScene == scene, let refScene = shadowWindow.refWindow.windowScene { ShadowWindow.shared?.refWindow.windowScene?.delegate?.sceneDidBecomeActive?(refScene) } - + // 2. Set space controller back and refresh layout - + let spCtrl = _spCtrl - + if window.rootViewController != spCtrl { window.rootViewController = spCtrl } - + guard let term = spCtrl.currentTerm() else { return } - + term.resumeIfNeeded() term.view?.setNeedsLayout() - + if let paywallWindow = paywallWindow { _ = KBTracker.shared.input?.resignFirstResponder() paywallWindow.makeKeyAndVisible() return; } - - // We can present config or stuck view. + + // We can present config or stuck view. guard spCtrl.presentedViewController == nil else { return } - + // 3. Stuck Key Check - + let input = KBTracker.shared.input if let key = input?.stuckKey() { debugPrint("BK:", "stuck!!!") input?.setTrackingModifierFlags([]) - + if input?.isHardwareKB == true && key == .commandLeft { let ctrl = UIHostingController(rootView: StuckView(keyCode: key, dismissAction: { spCtrl.onStuckOpCommand() })) - + ctrl.modalPresentationStyle = .formSheet spCtrl.stuckKeyCode = key spCtrl.present(ctrl, animated: false) @@ -410,11 +371,11 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { return } } - + spCtrl.stuckKeyCode = nil - + // 4. Focus Check - + if input == nil { DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 0.1) { if self.paywallWindow != nil { @@ -428,7 +389,7 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { } return } - + if term.termDevice.view?.isFocused() == false, input?.isRealFirstResponder == false, input?.window === window { @@ -441,10 +402,10 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { spCtrl.focusOnShellAction() } } - + return } - + if input?.window === window { DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 0.1) { if self.paywallWindow != nil { @@ -458,15 +419,15 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { return } - + input?.reportStateReset() } - + func stateRestorationActivity(for scene: UIScene) -> NSUserActivity? { _setDummyVC() return _spCtrl.stateRestorationActivity() } - + private func _setDummyVC() { if let _ = _spCtrl.presentedViewController { return @@ -476,7 +437,7 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { window?.rootViewController = _ctrl _ctrl.view.addSubview(_spCtrl.view) } - + @objc var spaceController: SpaceController { _spCtrl } } @@ -490,30 +451,30 @@ fileprivate extension URL { // MARK: Manage the `scene(_:openURLContexts:)` actions extension SceneDelegate { - + /* Handles the `ssh://` URL schemes and x-callback-url for devices that are running iOS 13 or higher. - Parameters: - xCallbackUrl: The x-callback-url specified by the user */ private func _handleSshUrlScheme(with sshUrl: URL) { - + var sshCommand = "ssh" - + // Progressively unwrap all of the parameters available on the URL to form // the SSH command to be later passed to the shell if let port = sshUrl.port { sshCommand += " -p \(port)" } - + if let username = sshUrl.user { sshCommand += " \(username)@" } - + if let host = sshUrl.host { sshCommand += "\(host)" } - + guard let term = _spCtrl.currentTerm() else { return } @@ -524,7 +485,7 @@ extension SceneDelegate { term.termDevice.write(sshCommand) return } - + // If a SSH/mosh connection is already open in the current terminal shell // create a new one and then write the command _spCtrl.newShellAction() @@ -537,88 +498,88 @@ extension SceneDelegate { newTerm.termDevice.write(sshCommand) } } - + /** Handles the x-callback-url, if a successful `x-success` URL is provided when being called from apps like Shortcuts it returns to the original app after a successful execution. - Parameters: - xCallbackUrl: The x-callback-url specified by the user, URL format should be `blinkshell://run?key=KEY&cmd=CMD%20ENCODED` */ private func _handleXcallbackUrl(with xCallbackUrl: URL) { - + let components = URLComponents(url: xCallbackUrl, resolvingAgainstBaseURL: true) - + var xCancelURL: URL? var xSuccessURL: URL? var xErrorURL: URL? - + guard let items = components?.queryItems else { return } - + if let xCancel = items.first(where: { $0.name == "x-cancel" })?.value { xCancelURL = URL(string: xCancel) } - + if let xError = items.first(where: { $0.name == "x-error" })?.value { xErrorURL = URL(string: xError) } - + if let xSuccess = items.first(where: { $0.name == "x-success" })?.value { xSuccessURL = URL(string: xSuccess) } - + guard case xCallbackUrl.host = "run" else { if let xErrorURL = xErrorURL { blink_openurl(xErrorURL) } return } - + guard BLKDefaults.isXCallBackURLEnabled() else { if let xCancelURL = xCancelURL { blink_openurl(xCancelURL) } return } - + // Cancel execution of the command if the x-callback-url doesn't have a // key field present that is needed to allow URL actions guard let keyItem: String = items.first(where: { $0.name == "key" })?.value else { - + if let xCancelURL = xCancelURL { blink_openurl(xCancelURL) } - + return } - + // Cancel the execution of the command as x-callback-url are not // enabled for the user's or the x-callback-url does not have // the correct key set guard keyItem == BLKDefaults.xCallBackURLKey() else { - + if let xErrorURL = xErrorURL { blink_openurl(xErrorURL) } return } - + guard let cmdItem: String = items.first(where: { $0.name == "cmd" })?.value else { if let xErrorURL = xErrorURL { blink_openurl(xErrorURL) } return } - + guard let term = _spCtrl.currentTerm() else { if let xErrorURL = xErrorURL { blink_openurl(xErrorURL) } return } - + _spCtrl.focusOnShellAction() - + // If SSH/mosh session is already open in the current terminal shell // create a new one and then write the SSH command guard term.isRunningCmd() else { @@ -627,7 +588,7 @@ extension SceneDelegate { term.xCallbackLineSubmitted(cmdItem, xSuccessURL) return } - + // If a SSH/mosh connection is already open in the current terminal shell // create a new one and then write the command _spCtrl.newShellAction() @@ -640,7 +601,7 @@ extension SceneDelegate { newTerm.xCallbackLineSubmitted(cmdItem, xSuccessURL) } } - + // vscode:// // vscode:// // vscode://github.codespaces/connect?name= @@ -699,7 +660,7 @@ extension SceneDelegate { newTerm.termDevice.write("\n") } } - + private func _handleHttpUrlScheme(with url: URL) { _spCtrl.newShellAction() guard let newTerm = _spCtrl.currentTerm() else { diff --git a/Blink/SmarterKeys/KBAccessoryView.swift b/Blink/SmarterKeys/KBAccessoryView.swift index fb008c892..f72e47ba4 100644 --- a/Blink/SmarterKeys/KBAccessoryView.swift +++ b/Blink/SmarterKeys/KBAccessoryView.swift @@ -66,3 +66,9 @@ class KBAccessoryView: UIInputView { return CGSize(width: -1, height: h) } } + +extension KBAccessoryView: UIInputViewAudioFeedback { + var enableInputClicksWhenVisible: Bool { + return !BKUserConfigurationManager.userSettingsValue(forKey: BKUserConfigMuteSmartKeysPlaySound) + } +} diff --git a/Blink/SmarterKeys/KBDevice.swift b/Blink/SmarterKeys/KBDevice.swift index bce0673ac..19ca0cc4a 100644 --- a/Blink/SmarterKeys/KBDevice.swift +++ b/Blink/SmarterKeys/KBDevice.swift @@ -105,6 +105,8 @@ enum KBDevice { // | 7 | iPhone 14 Pro | 852 | 693 | | | // | 8 | iPhone 14 Pro Max | 932 | 812 | | | // | 9 | iPhone SE 3d-gen | 667 | 568 | | | + // | | iPhone 16 Pro | 874 | 693 | | | + // | | iPhone 16 Pro Max | 956 | 812 | | | // | 10 | iPad 7th-gen | 1080 | | | | // | 11 | iPad 8th-gen | 1080 | | | | // | 12 | iPad 9th-gen | 1180 | | | | @@ -116,6 +118,12 @@ enum KBDevice { // | 18 | iPad Pro 11" 3d-gen | 1194 | | 1389 | tab | // | 18 | iPad Pro 11" 4d-gen | 1194 | | 1389 | tab | // | 19 | iPad Pro 12" 6th-gen | 1366 | 1024 | 1590 | tab | + // | 20 | iPad Air 11" M2 | 1180 | | 1373 | tab | + // | 21 | iPad Air 13" M2 | 1366 | 1024 | 1590 | tab | + // | 22 | iPad Pro 11" M4 17.4 | 1194 | | 1408 | tab | + // | 22 | iPad Pro 11" M4 17.5 | 1210 | | 1408 | tab | + // | 23 | iPad Pro 13" M4 | 1376 | 1032 | 1600 | tab | + // | 23'| iPad Pro 13" M4 17.4 | 1366 | 1024 | 1590 | tab | // +----+----------------------+------+------+------+-----+ switch wideSideSize { @@ -126,10 +134,12 @@ enum KBDevice { case 812: return .in5_8 // iPhone 11 Pro, iPhone 12 Pro Max ZLT case 844: return .in6_1 // iPhone 12 Pro, iPhone 14 case 852: return .in6_1 // iPhone 14 Pro + case 874: return .in6_1 // iPhone 16 case 896: return .in6_5 // iPhone 11 Pro Max case 926: return .in6_7 // iPhone 12 Pro Max, iPhone 14 Plus case 932: return .in6_7 // iPhone 14 Pro Max - case 1024: + case 956: return .in6_7 + case 1024, 1032: // tune for ipad 12 ZLT return DeviceInfo.shared().hasCorners ? .in12_9 : .in9_7 // iPad 12.9 ZLT case 1080: return .in10_2 // TODO: Tune for iPad 10th-gen @@ -144,7 +154,10 @@ enum KBDevice { case 1389: return .in11_MoreSpace case 1366: return .in12_9 case 1590: return .in12_9 // iPad 12.9 ZMS - + case 1210: return .in11 + case 1408: return .in11_MoreSpace + case 1376: return .in12_9 + case 1600: return .in12_9 default: // Safe fallback print("KBDevice: unknown device with size:", size) diff --git a/Blink/SmarterKeys/KBKey.swift b/Blink/SmarterKeys/KBKey.swift index 084a94a1d..955782a13 100644 --- a/Blink/SmarterKeys/KBKey.swift +++ b/Blink/SmarterKeys/KBKey.swift @@ -95,15 +95,6 @@ extension KBKey: Identifiable { var id: String { shape.id } } -extension KBKey { - var sound: KBSound { - switch shape.primaryValue { - case .text: return .text - default: return .modifier - } - } -} - extension KBKey { var isModifier: Bool { switch shape.primaryValue { diff --git a/Blink/SmarterKeys/KBView.swift b/Blink/SmarterKeys/KBView.swift index 713dad761..7c681c57a 100644 --- a/Blink/SmarterKeys/KBView.swift +++ b/Blink/SmarterKeys/KBView.swift @@ -286,7 +286,7 @@ class KBView: UIView { return } - view.key.sound.playIfPossible() + UIDevice.current.playInputClick() view.keyDelegate.keyViewTriggered(keyView: view, value: view.currentValue) } } diff --git a/Blink/SmarterKeys/KeyViews/KBKeyView.swift b/Blink/SmarterKeys/KeyViews/KBKeyView.swift index bc1318a35..fdb7a5743 100644 --- a/Blink/SmarterKeys/KeyViews/KBKeyView.swift +++ b/Blink/SmarterKeys/KeyViews/KBKeyView.swift @@ -141,6 +141,8 @@ class KBKeyView: UIView { backgroundColor = .tertiarySystemBackground keyDelegate.keyViewOn(keyView: self, value: currentValue) - key.sound.playIfPossible() + UIDevice.current.playInputClick() } } + + diff --git a/Blink/SmarterKeys/KeyViews/KBKeyViewArrows.swift b/Blink/SmarterKeys/KeyViews/KBKeyViewArrows.swift index 816a6b1f2..ebe1edad3 100644 --- a/Blink/SmarterKeys/KeyViews/KBKeyViewArrows.swift +++ b/Blink/SmarterKeys/KeyViews/KBKeyViewArrows.swift @@ -92,7 +92,7 @@ class KBKeyViewArrows: KBKeyView { return } - view.key.sound.playIfPossible() + UIDevice.current.playInputClick() view.keyDelegate.keyViewTriggered(keyView: view, value: value) } } diff --git a/Blink/SmarterKeys/SmarterTermInput.swift b/Blink/SmarterKeys/SmarterTermInput.swift index d947bb288..240bcb792 100644 --- a/Blink/SmarterKeys/SmarterTermInput.swift +++ b/Blink/SmarterKeys/SmarterTermInput.swift @@ -121,9 +121,6 @@ class CaretHider { } else { _setupAccessoryView() } - - KBSound.isMutted = BKUserConfigurationManager.userSettingsValue(forKey: BKUserConfigMuteSmartKeysPlaySound) - } override func layoutSubviews() { @@ -402,13 +399,6 @@ extension SmarterTermInput { deviceView.displayInput(data) - let ctrlC = "\u{0003}" - let ctrlD = "\u{0004}" - - if data == ctrlC || data == ctrlD, - device.delegate?.handleControl(data) == true { - return - } device.write(data) } @@ -493,8 +483,6 @@ extension SmarterTermInput { extension SmarterTermInput { @objc private func _updateSettings() { - KBSound.isMutted = BKUserConfigurationManager.userSettingsValue(forKey: BKUserConfigMuteSmartKeysPlaySound) - // let hideSmartKeysWithHKB = !BKUserConfigurationManager.userSettingsValue(forKey: BKUserConfigShowSmartKeysWithXKeyBoard) // // if hideSmartKeysWithHKB != hideSmartKeysWithHKB { diff --git a/Blink/Snippets/SearchTextInput.swift b/Blink/Snippets/SearchTextInput.swift index bd5059b6a..bcddc486b 100644 --- a/Blink/Snippets/SearchTextInput.swift +++ b/Blink/Snippets/SearchTextInput.swift @@ -128,12 +128,6 @@ class SearchTextInput: UITextField, UITextFieldDelegate { self.leftView = label self.leftViewMode = .always - -// self.placeholder = "Type SnipName␣Content Search" -// self.placeholder = "[FuzzySnipName]␣" -// self.textContentType = .password -// self.isSecureTextEntry = true - self.delegate = self diff --git a/Blink/Snippets/SnippetEditingView.swift b/Blink/Snippets/SnippetEditingView.swift index 3b555d6d7..e1265bcd8 100644 --- a/Blink/Snippets/SnippetEditingView.swift +++ b/Blink/Snippets/SnippetEditingView.swift @@ -82,7 +82,7 @@ class TextViewBuilder { public final class PragmataProTheme: Runestone.Theme { - public var font = BlinkFonts.snippetEditContent + public var font = BlinkSnippetsFonts.snippetEditContent public var textColor: UIColor { originalTheme.textColor diff --git a/Blink/Snippets/SnippetView.swift b/Blink/Snippets/SnippetView.swift index 2d9d4c711..67d778662 100644 --- a/Blink/Snippets/SnippetView.swift +++ b/Blink/Snippets/SnippetView.swift @@ -47,14 +47,14 @@ public struct SnippetView: View { } label: { VStack(alignment: .leading) { HStack { - Text(index).font(Font(BlinkFonts.snippetEditContent)).bold(fuzzyMode) + Text(index).font(Font(BlinkSnippetsFonts.snippetEditContent)).bold(fuzzyMode) .frame(maxWidth: .infinity, alignment: .leading).opacity(fuzzyMode ? 1.0 : 0.4) if selected { Spacer() Text(Image(systemName: "return")).opacity(0.5) } } - Text(content).font(Font(BlinkFonts.snippetEditContent)) + Text(content).font(Font(BlinkSnippetsFonts.snippetEditContent)) .frame(maxWidth: .infinity, alignment: .leading) .opacity(fuzzyMode ? 0.4 : 1.0) } diff --git a/Blink/Snippets/SnippetsListView.swift b/Blink/Snippets/SnippetsListView.swift index 68ad85d61..64a2bfe3d 100644 --- a/Blink/Snippets/SnippetsListView.swift +++ b/Blink/Snippets/SnippetsListView.swift @@ -33,6 +33,12 @@ import Foundation import SwiftUI import BlinkSnippets +public enum BlinkSnippetsFonts { + // static let snippetIndex = Font.custom(BLINK_APP_FONT_NAME, size: 18, relativeTo: .body) + // static let snippetContent = Font.custom(BLINK_APP_FONT_NAME, size: 18, relativeTo: .body) + static let snippetEditContent = UIFont(name: BLINK_APP_FONT_NAME, size: 18)! +} + public struct SnippetsListView: View { @ObservedObject var model: SearchModel diff --git a/Blink/Snippets/SnippetsLocations.swift b/Blink/Snippets/SnippetsLocations.swift index eb0011eb7..6544ff982 100644 --- a/Blink/Snippets/SnippetsLocations.swift +++ b/Blink/Snippets/SnippetsLocations.swift @@ -63,7 +63,7 @@ class SnippetsLocations { let dontUseBlinkSnippets = BLKDefaults.dontUseBlinkSnippetsIndex() let snippetsLocation = BlinkPaths.localSnippetsLocationURL()! - let icloudSnippetsLocation = BlinkPaths.iCloudSnippetsLocationURL()! + let icloudSnippetsLocation = BlinkPaths.iCloudSnippetsLocationURL() let cachedSnippetsLocation = snippetsLocation.appending(path: ".cached") // Create main snippets location. Each location then is responsible for its structure. @@ -74,16 +74,20 @@ class SnippetsLocations { } } - if useiCloud { - if !fm.fileExists(atPath: icloudSnippetsLocation.path()) { - try fm.createDirectory(at: icloudSnippetsLocation, withIntermediateDirectories: true) + if useiCloud, let location = icloudSnippetsLocation { + if !fm.fileExists(atPath: location.path) { + try fm.createDirectory(at: location, withIntermediateDirectories: true) } } // ".blink/snippets" for local // ".blink/snippets/.cached/com.github" for github // ".iCloud/snippets/ for icloud - let defaultLocation = useiCloud ? iCloudSnippets(from: icloudSnippetsLocation): LocalSnippets(from: snippetsLocation) + let defaultLocation = if useiCloud, let location = icloudSnippetsLocation { + iCloudSnippets(from: location) + } else { + LocalSnippets(from: snippetsLocation) + } // Locations are sorted by priority. var locations = [defaultLocation] diff --git a/Blink/SpaceController.swift b/Blink/SpaceController.swift index 384e6658c..1fef60f28 100644 --- a/Blink/SpaceController.swift +++ b/Blink/SpaceController.swift @@ -38,6 +38,7 @@ import MBProgressHUD import SwiftUI +// MARK: UIViewController class SpaceController: UIViewController { struct UIState: UserActivityCodable { @@ -112,7 +113,8 @@ class SpaceController: UIViewController { } } let windowBounds = window.bounds - _bottomTapAreaView.frame = CGRect(x: windowBounds.width * 0.5 - 250, y: windowBounds.height - 18, width: 250 * 2, height: 18) + let height: CGFloat = 22 + _bottomTapAreaView.frame = CGRect(x: windowBounds.width * 0.5 - 250, y: windowBounds.height - height, width: 250 * 2, height: height) // _bottomTapAreaView.backgroundColor = UIColor.red self.view.bringSubviewToFront(_bottomTapAreaView); @@ -228,9 +230,24 @@ class SpaceController: UIViewController { // view.addSubview(_faceCam) // addChild(_faceCam.controller) + DispatchQueue.main.asyncAfter(deadline: .now() + 5.0) { self.alertSubscriptionGroupViolation() } + } + + func alertSubscriptionGroupViolation() { + // NOTE: Added just in case, as I have seen in RevCat some users ending up in both groups (bc + // things can still be selected outside the App). + let msg = """ +You may be in two different subscription groups and hence, you may end up overpaying for Blink. +Please go to your subscriptions and cancel one of them! +""" + + if EntitlementsManager.shared.groupsCheckViolation() { + let ctrl = UIAlertController(title: "Important!", message: msg, preferredStyle: .alert) + ctrl.addAction(UIAlertAction(title: "Ok", style: .default)) + self.present(ctrl, animated: true) + } } - func showAlert(msg: String) { let ctrl = UIAlertController(title: "Error", message: msg, preferredStyle: .alert) ctrl.addAction(UIAlertAction(title: "Ok", style: .default)) @@ -535,6 +552,7 @@ class SpaceController: UIViewController { } +// MARK: UIStateRestorable extension SpaceController: UIStateRestorable { func restore(withState state: UIState) { _viewportsKeys = state.keys @@ -565,6 +583,7 @@ extension SpaceController: UIStateRestorable { } } +// MARK: UIPageViewControllerDelegate extension SpaceController: UIPageViewControllerDelegate { public func pageViewController( _ pageViewController: UIPageViewController, @@ -579,12 +598,15 @@ extension SpaceController: UIPageViewControllerDelegate { else { return } + termController.resumeIfNeeded() _currentKey = termController.meta.key _displayHUD() _attachInputToCurrentTerm() + } } +// MARK: UIPageViewControllerDataSource extension SpaceController: UIPageViewControllerDataSource { private func _controller(controller: UIViewController, advancedBy: Int) -> UIViewController? { guard let ctrl = controller as? TermController else { @@ -615,6 +637,7 @@ extension SpaceController: UIPageViewControllerDataSource { } +// MARK: TermControlDelegate extension SpaceController: TermControlDelegate { func terminalHangup(control: TermController) { @@ -675,7 +698,7 @@ extension SpaceController { // input.reportStateReset() switch cmd.bindingAction { - case .hex(let hex, comment: _): + case .hex(let hex, stringInput: _, comment: _): input.reportHex(hex) case .press(let keyCode, mods: let mods): input.reportPress(UIKeyModifierFlags(rawValue: mods), keyId: keyCode.id) @@ -917,32 +940,32 @@ extension SpaceController { } DispatchQueue.main.async { + _ = KBTracker.shared.input?.resignFirstResponder() let navCtrl = UINavigationController() navCtrl.navigationBar.prefersLargeTitles = true - let s = SettingsHostingController.createSettings(nav: navCtrl) + let s = SettingsHostingController.createSettings(nav: navCtrl, onDismiss: { + [weak self] in self?._focusOnShell() + }) navCtrl.setViewControllers([s], animated: false) self.present(navCtrl, animated: true, completion: nil) } } - @objc func showWalkthroughAction() { - if self.view.window == ShadowWindow.shared { - return - } - DispatchQueue.main.async { - _ = KBTracker.shared.input?.resignFirstResponder() - let ctrl = UIHostingController(rootView: WalkthroughView(urlHandler: blink_openurl, - dismissHandler: { self.dismiss(animated: true) }) - ) - ctrl.modalPresentationStyle = .formSheet - self.present(ctrl, animated: false) - } - } +// @objc func showWalkthroughAction() { +// if self.view.window == ShadowWindow.shared { +// return +// } +// DispatchQueue.main.async { +// _ = KBTracker.shared.input?.resignFirstResponder() +// let ctrl = UIHostingController(rootView: WalkthroughView(urlHandler: blink_openurl, +// dismissHandler: { self.dismiss(animated: true) }) +// ) +// ctrl.modalPresentationStyle = .formSheet +// self.present(ctrl, animated: false) +// } +// } @objc func showSnippetsAction() { - guard EntitlementsManager.shared.earlyAccessFeatures.active || FeatureFlags.earlyAccessFeatures else { - return - } if let _ = _snippetsVC { return } @@ -965,11 +988,7 @@ extension SpaceController { self.view.addSubview(menu.tapToCloseView) var ids: [BlinkActionID] = [] - if EntitlementsManager.shared.earlyAccessFeatures.active || FeatureFlags.earlyAccessFeatures { - ids.append(contentsOf: [.snippets]) - } - - ids.append(contentsOf: [.tabClose, .tabCreate]) + ids.append(contentsOf: [.snippets, .tabClose, .tabCreate]) if DeviceInfo.shared().hasCorners { ids.append(contentsOf: [.layoutMenu]) @@ -1108,6 +1127,7 @@ extension SpaceController { _spaceControllerAnimating = true _viewportsController.setViewControllers([term], direction: direction, animated: animated) { (didComplete) in + term.resumeIfNeeded() self._currentKey = term.meta.key self._displayHUD() self._attachInputToCurrentTerm() @@ -1145,6 +1165,7 @@ extension SpaceController { } +// MARK: CommandsHUDDelegate extension SpaceController: CommandsHUDDelegate { @objc func currentTerm() -> TermController? { if let currentKey = _currentKey { @@ -1156,6 +1177,8 @@ extension SpaceController: CommandsHUDDelegate { @objc func spaceController() -> SpaceController? { self } } +// MARK: SnippetContext + extension SpaceController: SnippetContext { func _presentSnippetsController(receiver: SpaceController) { @@ -1199,5 +1222,3 @@ extension SpaceController: SnippetContext { } } - - diff --git a/Blink/Subscriptions/EntitlementsManager.swift b/Blink/Subscriptions/EntitlementsManager.swift index e1fccc804..f1dc82d64 100644 --- a/Blink/Subscriptions/EntitlementsManager.swift +++ b/Blink/Subscriptions/EntitlementsManager.swift @@ -43,23 +43,17 @@ let ProductBlinkShellClassicID = "blink_shell_classic_unlimited_0" let ProductBlinkBuildBasicID = "blink_build_basic_1m_799" let ProductBlinkPlusBuildBasicID = "blink_plus_build_1m_999" - -private let NagTimestamp = "NagTimestamp" -extension Notification.Name { - public static let subscriptionNag = Notification.Name("SubscriptionNag") -} - // Decoupled from RevCat Entitlement public struct Entitlement: Identifiable, Equatable, Hashable { public let id: String public var active: Bool public var unlockProductID: String? public var period: EntitlementPeriodType - + public static var inactiveUnlimitedScreenTime = Self(id: UnlimitedScreenTimeEntitlementID, active: false, unlockProductID: nil, period: .None) - + public static var earlyAccessFeatures = Self(id: EarlyAccessFeaturesEntitlementID, active: false, unlockProductID: nil, period: .None) - + public static var build = Self(id: BuildEntitlementID, active: false, unlockProductID: nil, period: .None) } @@ -87,11 +81,7 @@ public class EntitlementsManager: ObservableObject, EntitlementsSourceDelegate { @Published var activeSubscriptions: Set = .init() @Published var nonSubscriptionTransactions: Set = .init() - @Published var isUnknownState: Bool = true - public var navigationCtrl: UINavigationController? = nil - @Published var navigationSteps: [EarlyFeatureAccessSteps] = [] - private let _sources: [EntitlementsSource] private init(_ sources: [EntitlementsSource]) { @@ -113,11 +103,6 @@ public class EntitlementsManager: ObservableObject, EntitlementsSourceDelegate { activeSubscriptions: Set, nonSubscriptionTransactions: Set ) { - - defer { - self.isUnknownState = false - } - // TODO: merge stategy from multiple sources self.activeSubscriptions = activeSubscriptions self.nonSubscriptionTransactions = nonSubscriptionTransactions @@ -134,48 +119,6 @@ public class EntitlementsManager: ObservableObject, EntitlementsSourceDelegate { if let newValue = entitlements[BuildEntitlementID] { self.build = newValue } - - if isUnknownState { - _updateSubscriptionNag() - } else { - if oldValue.active != self.unlimitedTimeAccess.active { - _updateSubscriptionNag() - } - } - - } - - private func _updateSubscriptionNag() { - showPaywall() - } - - @Published var keepShowingPaywall: Bool = false - @Published var shouldDismissPaywall: Bool = false - - func showPaywall(force: Bool = false) { - keepShowingPaywall = force - NotificationCenter.default.post(name: .subscriptionNag, object: nil) - } - - func doShowPaywall() -> Bool { - if keepShowingPaywall { - return true - } - if shouldDismissPaywall { - return false - } - - if ProcessInfo().isMacCatalystApp { - return false - } - return !(self.unlimitedTimeAccess.active || FeatureFlags.earlyAccessFeatures) - } - - func dismissPaywall() { - keepShowingPaywall = false - shouldDismissPaywall = true - NotificationCenter.default.post(name: .subscriptionNag, object: nil) - shouldDismissPaywall = false } public func currentPlanName() -> String { @@ -183,10 +126,18 @@ public class EntitlementsManager: ObservableObject, EntitlementsSourceDelegate { return "TestFlight Plan" } if activeSubscriptions.contains(ProductBlinkShellPlusID) { - return "Blink+ Plan" + if self.earlyAccessFeatures.period == .Trial { + return "Blink+ Trial" + } else { + return "Blink+ Plan" + } } if activeSubscriptions.contains(ProductBlinkPlusID) { - return "Blink+ Plan" + if self.earlyAccessFeatures.period == .Trial { + return "Blink+ Trial" + } else { + return "Blink+ Plan" + } } if activeSubscriptions.contains(ProductBlinkPlusBuildBasicID) { return "Blink+Build Plan" @@ -198,7 +149,9 @@ public class EntitlementsManager: ObservableObject, EntitlementsSourceDelegate { } public func customerTier() -> CustomerTier { - if activeSubscriptions.contains(ProductBlinkShellPlusID) || activeSubscriptions.contains(ProductBlinkPlusID){ + if activeSubscriptions.contains(ProductBlinkShellPlusID) || activeSubscriptions.contains(ProductBlinkPlusID) + || activeSubscriptions.contains(ProductBlinkPlusBuildBasicID) + { return CustomerTier.Plus } if nonSubscriptionTransactions.contains(ProductBlinkShellClassicID) { @@ -207,9 +160,24 @@ public class EntitlementsManager: ObservableObject, EntitlementsSourceDelegate { if PublishingOptions.current == .testFlight { return CustomerTier.TestFlight } - + return CustomerTier.Free } + + public func hasActiveSubscriptions() -> Bool { + print(currentPlanName()) + return customerTier() != CustomerTier.Free + } + + public func groupsCheckViolation() -> Bool { + if activeSubscriptions.contains(ProductBlinkPlusBuildBasicID) && + (activeSubscriptions.contains(ProductBlinkPlusID) || + activeSubscriptions.contains(ProductBlinkShellPlusID)) { + return true + } + + return false + } } public enum CustomerTier { diff --git a/Blink/Subscriptions/Intro.swift b/Blink/Subscriptions/Intro.swift new file mode 100644 index 000000000..cf19dff1b --- /dev/null +++ b/Blink/Subscriptions/Intro.swift @@ -0,0 +1,724 @@ +////////////////////////////////////////////////////////////////////////////////// +// +// B L I N K +// +// Copyright (C) 2016-2019 Blink Mobile Shell Project +// +// This file is part of Blink. +// +// Blink is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Blink is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Blink. If not, see . +// +// In addition, Blink is also subject to certain additional terms under +// GNU GPL version 3 section 7. +// +// You should have received a copy of these additional terms immediately +// following the terms and conditions of the GNU General Public License +// which accompanied the Blink Source Code. If not, see +// . +// +//////////////////////////////////////////////////////////////////////////////// + +import SwiftUI + +import ConfettiSwiftUI + +enum BlinkColors { + static let bg = Color(red: 20.0 / 256.0, green: 30.0 / 256.0 , blue: 33.0 / 256.0) +// static let yellow = Color(red: 255.0 / 256.0, green: 184.0 / 256.0, blue: 0.0 / 256.0) + static let blink = Color(red: 10.0 / 256.0, green: 224.0 / 256.0, blue: 240.0 / 256.0) + static let build = Color(red: 116.0 / 256.0, green: 251.0 / 256.0, blue: 152.0 / 256.0) + static let code = Color(red: 255.0 / 256.0, green: 184.0 / 256.0, blue: 0.0 / 256.0) + + static let secondaryBtnBG = Color(red: 16.0 / 256.0, green: 40.0 / 256.0, blue: 41.0 / 256.0) + static let secondaryBtnText = Color(red: 10.0 / 256.0, green: 224.0 / 256.0, blue: 240.0 / 256.0) + static let secondaryBtnBorder = Color(red: 42.0 / 256.0, green: 80.0 / 256.0, blue: 83.0 / 256.0) + + static let primaryBtnBG = Color(red: 86.0 / 256.0, green: 62.0 / 256.0, blue: 0.0 / 256.0) + static let primaryBtnText = BlinkColors.code + static let primaryBtnBorder = Color(red: 168.0 / 256.0, green: 121.0 / 256.0, blue: 0.0 / 256.0) + + static let ctaBtnBG = Color(red: 1.0 / 256.0, green: 67.0 / 256.0, blue: 76.0 / 256.0) + static let ctaBtnText = BlinkColors.blink + static let ctaBtnBorder = BlinkColors.blink + + static let headerText = BlinkColors.code + static let infoText = Color(red: 195.0 / 256.0, green: 219.0 / 256.0, blue: 219.0 / 256.0) + + static let linearGradient1 = Color(red: 40.0 / 256.0, green: 100.0 / 256.0, blue: 111.0 / 256.0) +// static let linearGradient2 = Color(red: 9.0 / 256.0, green: 13.0 / 256.0, blue: 14.0 / 256.0) + static let linearGradient2 = Color(red: (10 + 9.0) / 256.0, green: (10 + 13.0) / 256.0, blue: (10 + 14.0) / 256.0) + + static let radialGradient1 = Color(red: 1.0 / 256.0, green: 4.0 / 256.0, blue: 4.0 / 256.0) + static let radialGradient2 = Color(red: 20.0 / 256.0, green: 33.0 / 256.0, blue: 33.0 / 256.0, opacity: 0) + + static let blinkBG = Color(red: 16.0 / 256.0, green: 40.0 / 256.0, blue: 41.0 / 256.0) + static let buildBG = Color(red: 24.0 / 256.0, green: 56.0 / 256.0, blue: 32.0 / 256.0) + static let codeBG = Color(red: 86.0 / 256.0, green: 62.0 / 256.0, blue: 0.0 / 256.0) + + static let blinkText = Color(red: 195.0 / 256.0, green: 219.0 / 256.0, blue: 219.0 / 256.0) + static let buildText = Color(red: 207.0 / 256.0, green: 241.0 / 256.0, blue: 216.0 / 256.0) + static let codeText = Color(red: 240.0 / 256.0, green: 221.0 / 256.0, blue: 171.0 / 256.0) + + static let termsText = Color(red: 92.0 / 256.0, green: 117.0 / 256.0, blue: 117.0 / 256.0) + + static let purchase = Color(red: 149.0 / 256.0, green: 104.0 / 256.0, blue: 203.0 / 256.0) + +// #5C7575 +} + +let BLINK_APP_FONT_NAME: String = Bundle.main.infoDictionary?["BLINK_APP_FONT"] as? String ?? "JetBrains Mono" + +public enum BlinkFonts { + static let header = Font.custom(BLINK_APP_FONT_NAME, size: 34, relativeTo: .title) + static let headerCompact = Font.custom(BLINK_APP_FONT_NAME, size: 28, relativeTo: .title) + + static let info = Font.system(.title3) + static let infoCompact = Font.system(.body) + static let btn = Font.custom(BLINK_APP_FONT_NAME, size: 16, relativeTo: .body) + static let btnSub = Font.custom(BLINK_APP_FONT_NAME, size: 12, relativeTo: .body) + + static let bullet = Font.custom(BLINK_APP_FONT_NAME, size: 24, relativeTo: .body).weight(.bold) + static let bulletCompact = Font.custom(BLINK_APP_FONT_NAME, size: 18, relativeTo: .body).weight(.bold) + static let bulletText = Font.custom(BLINK_APP_FONT_NAME, size: 18, relativeTo: .body).weight(.bold) + static let bulletTextCompact = Font.custom(BLINK_APP_FONT_NAME, size: 14, relativeTo: .body).weight(.bold) + + static let offeringSubheader = Font.body.weight(.bold) + static let offeringCompactSubheader = Font.footnote.weight(.bold) + static let offeringInfo = Font.system(.body) + static let offeringInfoCompact = Font.footnote +} + +extension Shape { + func fill(_ fillStyle: Fill, strokeBorder strokeStyle: Stroke, lineWidth: Double = 1) -> some View { + self + .stroke(strokeStyle, lineWidth: lineWidth) + .background(self.fill(fillStyle)) + } +} + +struct BlinkButtonWithoutHoverStyle: ButtonStyle { + let textColor: Color + let bgColor: Color + let borderColor: Color + let disabled: Bool + let inProgress: Bool + let minWidth: CGFloat? + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .multilineTextAlignment(.center) + .lineSpacing(5.0) + .font(BlinkFonts.btn) + .foregroundColor(inProgress ? bgColor : textColor) + + .padding(EdgeInsets(top: 16, leading: 28, bottom: 16, trailing: 28)) + .frame(minWidth: minWidth) + .background( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill((configuration.isPressed) ? borderColor : bgColor, strokeBorder: borderColor) + + ) + .opacity((disabled && !inProgress) ? 0.5 : 1.0) + + .overlay(Group { + if inProgress { + ProgressView().tint(textColor) + } + }) + } + + static func secondary(disabled: Bool, inProgress: Bool, minWidth: CGFloat? = nil) -> Self { + Self( + textColor: BlinkColors.secondaryBtnText, + bgColor: BlinkColors.secondaryBtnBG, + borderColor: BlinkColors.secondaryBtnBorder, + disabled: disabled, + inProgress: inProgress, + minWidth: minWidth + ) + } + + static func primary(disabled: Bool, inProgress: Bool, minWidth: CGFloat? = nil) -> Self { + Self( + textColor: BlinkColors.primaryBtnText, + bgColor: BlinkColors.primaryBtnBG, + borderColor: BlinkColors.primaryBtnBorder, + disabled: disabled, + inProgress: inProgress, + minWidth: minWidth + ) + } +} + +struct BlinkButtonStyle: ButtonStyle { + let textColor: Color + let bgColor: Color + let borderColor: Color + let disabled: Bool + let inProgress: Bool + let minWidth: CGFloat? + let cta: Bool + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .multilineTextAlignment(.center) + .lineSpacing(5.0) + .font(cta ? BlinkFonts.offeringSubheader : BlinkFonts.btn) + .foregroundColor(inProgress ? bgColor : textColor) + .padding(EdgeInsets(top: 16, leading: 28, bottom: 16, trailing: 28)) + .frame(minWidth: minWidth) + .background( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill((configuration.isPressed) ? borderColor : bgColor, strokeBorder: borderColor, lineWidth: cta ? 2.0 : 1.0) + + ) + .opacity((disabled && !inProgress) ? 0.5 : 1.0) + .overlay(Group { + if inProgress { + ProgressView().tint(textColor) + } + }) + .hoverEffect(.lift) + } + + static func cta(disabled: Bool, inProgress: Bool, minWidth: CGFloat? = nil) -> Self { + Self( + textColor: BlinkColors.ctaBtnText, + bgColor: BlinkColors.ctaBtnBG, + borderColor: BlinkColors.ctaBtnBorder, + disabled: disabled, + inProgress: inProgress, + minWidth: minWidth, + cta: true + ) + } + + static func secondary(disabled: Bool, inProgress: Bool, minWidth: CGFloat? = nil) -> Self { + Self( + textColor: BlinkColors.secondaryBtnText, + bgColor: BlinkColors.secondaryBtnBG, + borderColor: BlinkColors.secondaryBtnBorder, + disabled: disabled, + inProgress: inProgress, + minWidth: minWidth, + cta: false + ) + } + + static func primary(disabled: Bool, inProgress: Bool, minWidth: CGFloat? = nil) -> Self { + Self( + textColor: BlinkColors.primaryBtnText, + bgColor: BlinkColors.primaryBtnBG, + borderColor: BlinkColors.primaryBtnBorder, + disabled: disabled, + inProgress: inProgress, + minWidth: minWidth, + cta: false + ) + } +} + +struct PageCtx { + let proxy: GeometryProxy + let dynamicTypeSize: DynamicTypeSize + var horizontalCompact: Bool = false + var verticalCompact: Bool = false + let portrait: Bool + + func pagePadding() -> EdgeInsets { + if proxy.size.width < 500 || proxy.size.height < 600 { + return EdgeInsets(top: 20, leading: 10, bottom: 20, trailing: 10) + } else { + return EdgeInsets(top: 30, leading: 30, bottom: 30, trailing: 30) + } + } + + func outterPadding() -> CGFloat? { + if proxy.size.width < 500 || proxy.size.height < 600 { + return 0 + } + return nil + } + + func pagingPadding() -> EdgeInsets { + if proxy.size.width < 500 { + return EdgeInsets(top: 50, leading: -12, bottom: 50, trailing: -12) + } else if proxy.size.width < 700 { + return EdgeInsets(top: 50, leading: 0, bottom: 50, trailing: 0) + } else { + return EdgeInsets(top: 50, leading: 34, bottom: 50, trailing: 34) + } + } + + func headerFont() -> Font { + verticalCompact ? BlinkFonts.headerCompact : BlinkFonts.header + } + + func infoFont() -> Font { + (verticalCompact || horizontalCompact) ? BlinkFonts.infoCompact : BlinkFonts.info + } + + func offeringHeaderFont() -> Font { + verticalCompact ? BlinkFonts.headerCompact : BlinkFonts.header + } + + func offeringSubheaderFont() -> Font { + verticalCompact ? BlinkFonts.offeringCompactSubheader : BlinkFonts.offeringSubheader + } + + func offeringInfoFont() -> Font { + (verticalCompact || horizontalCompact) ? BlinkFonts.offeringInfoCompact : BlinkFonts.offeringInfo + } + + func bulletPadding() -> EdgeInsets { + if dynamicTypeSize.isAccessibilitySize { + return EdgeInsets(top: 2, leading: 2, bottom: 2, trailing: 2) + } + return horizontalCompact + ? EdgeInsets(top: 4, leading: 6, bottom: 4, trailing: 6) + : EdgeInsets(top: 6, leading: 12, bottom: 6, trailing: 12) + } + + func bulletFont() -> Font { + verticalCompact ? BlinkFonts.bulletCompact : BlinkFonts.bullet + } + + func bulletTextFont() -> Font { + verticalCompact ? BlinkFonts.bulletTextCompact : BlinkFonts.bulletText + } + + func pageMaxHeight() -> CGFloat { + if dynamicTypeSize <= .medium { + return 780 + } + + if dynamicTypeSize <= .large { + return 820 + } + + if dynamicTypeSize <= .xLarge { + return 900 + } + + if dynamicTypeSize <= .xxLarge { + return 1000 + } + + return 1200 + } + + init( + proxy: GeometryProxy, + dynamicTypeSize: DynamicTypeSize + ) { + self.proxy = proxy + self.dynamicTypeSize = dynamicTypeSize + self.horizontalCompact = proxy.size.width < 400 + self.verticalCompact = proxy.size.height < 706 + self.portrait = proxy.size.width < proxy.size.height + } +} + +struct BlinkClassicBulletPoints: View { + var body: some View { + VStack(alignment: .leading, spacing: 16) { + HStack(alignment: .top, spacing: 12) { + Image(systemName: "sparkles") + .foregroundColor(BlinkColors.blink) + .padding(.top, 2) + Text("**Invest in what you use**, support sustainable development that puts users first.") + .foregroundColor(BlinkColors.blinkText) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) + } + + HStack(alignment: .top, spacing: 12) { + Image(systemName: "person.3.fill") + .foregroundColor(BlinkColors.blink) + .padding(.top, 2) + Text("**Join the pro crowd**, get access to the same tools top users rely on every day.") + .foregroundColor(BlinkColors.blinkText) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) + } + } + } +} + +struct NewOfferingTermsButtons: View { + let ctx: PageCtx + @StateObject var _purchases = PurchasesUserModel.shared + @State var opacity: CGFloat = 0.5 + let purchaseCompletedHandler: () -> () + let urlHandler: (URL) -> () + + var body: some View { + VStack { + HStack { + Button("RESTORE") { + Task { + // The UI will show an alert and transition there. No need to check the status here. + let _ = await _purchases.restoreActiveAppSubscriptions(alertIfNone: true) + } + } + .foregroundColor(BlinkColors.termsText).font(BlinkFonts.btnSub) + + Text("•").foregroundColor(BlinkColors.termsText).font(BlinkFonts.btnSub) + + Button("FAQ") { + urlHandler(URL(string: "https://docs.blink.sh/faq#pricing")!) + } + .foregroundColor(BlinkColors.termsText).font(BlinkFonts.btnSub) + + Text("•").foregroundColor(BlinkColors.termsText).font(BlinkFonts.btnSub) + + Button("TERMS") { + _purchases.openTermsOfUse() + } + .foregroundColor(BlinkColors.termsText).font(BlinkFonts.btnSub) + + Text("•").foregroundColor(BlinkColors.termsText).font(BlinkFonts.btnSub) + Button("COPY ID") { + UIPasteboard.general.string = _purchases.getUserID() + } + .foregroundColor(BlinkColors.termsText).font(BlinkFonts.btnSub) + + } + .padding() + Text("\(UIApplication.blinkShortVersion())") + .foregroundColor(BlinkColors.termsText) + .font(BlinkFonts.btnSub) + + } + } +} + +let minButtonWidth: CGFloat = 300 + +struct PurchaseCompletedView: View { + let ctx: PageCtx + let walkthroughHandler: () -> () + let dismissHandler: () -> () + + @State private var startConfetti = false + + var body: some View { + ZStack { + VStack(spacing: 8) { + // TODO Same as with Build + Spacer() + Text("WELCOME TO BLINK SHELL!") + .font(ctx.offeringHeaderFont()) + .foregroundColor(BlinkColors.blinkText) + .multilineTextAlignment(.center) + .padding(.bottom, 30) + Text("Your device is small, but with Blink, it can take on Big Jobs. Let's get to work!") + .font(ctx.offeringSubheaderFont()) + .foregroundColor(BlinkColors.blinkText) + .multilineTextAlignment(.center) + VStack { + Button("Walkthrough the app.") { walkthroughHandler() } + .buttonStyle(BlinkButtonStyle.primary(disabled: false, inProgress: false)) + Spacer().frame(width: 20) + Button("Go to the shell.") { dismissHandler() } + .buttonStyle(BlinkButtonStyle.secondary(disabled: false, inProgress: false)) + } + Spacer() + } + .onAppear { startConfetti = true } + .confettiCannon(trigger: $startConfetti, repetitions: 3) + }.frame(maxWidth: .infinity) + .background(.black) + } +} + +struct IntroSetupsCarouselView: View { + @State private var carrouselIndex = 0 + private let images = ["intro-1", "intro-2", "intro-3", "intro-4", "intro-5", "intro-6"] + private let timer = Timer.publish(every: 5, on: .main, in: .common).autoconnect() + @State private var opacity: Double = 1.0 + + var body: some View { + + GeometryReader { geo in + VStack { + Spacer(minLength: geo.size.height * 0.05) + + carousel + .clipShape(RoundedRectangle(cornerRadius: 15)) // Properly clips to rounded rect + .overlay( + RoundedRectangle(cornerRadius: 15) + .stroke(BlinkColors.blinkBG, lineWidth: 2) + ) + .onReceive(timer) { _ in + withAnimation(.easeInOut(duration: 0.5)) { + opacity = 0.0 + } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + carrouselIndex = (carrouselIndex + 1) % images.count + withAnimation(.easeInOut(duration: 0.5)) { + opacity = 1.0 + } + } + } + .frame(maxWidth: .infinity) + } + } + } + + var carousel: some View { + ZStack { + ForEach(images.indices) { index in + if index == carrouselIndex { + Image(images[index]) + .resizable() + .scaledToFit() + .opacity(opacity) + } + } + } +// .frame(maxWidth: .infinity) +// TabView(selection: $carrouselIndex) { +// ForEach(Array(zip(images.indices, images)), id: \.0) { index, image in +// Image(image) +// .resizable() +// .aspectRatio(contentMode: .fill) +// .frame(maxWidth: .infinity) +// .clipped() // Prevents overflow +// .tag(index) +// } +// } + } + +} + +struct NewOfferingsView: View { + let classicOffering: Bool + let ctx: PageCtx + @StateObject var _purchases = PurchasesUserModel.shared + //@State var isBlinkPlusIntroOfferAvailable: Bool = false + @State var doTrialNotification = true + let purchaseCompletedHandler: () -> () + let urlHandler: ((URL) -> ()) + let dismissHandler: (() -> ())? + var osName: String { + UIDevice.current.userInterfaceIdiom == .pad ? "iPadOS" : "iOS" + } + + private var headerText: some View { + TypingText(fullText: "THE PRO TERMINAL FOR \(osName)", cursor: "█", style: { + $0.font(ctx.offeringHeaderFont()) + .foregroundColor(BlinkColors.blinkText) + }) + .fixedSize(horizontal: false, vertical: true) + .multilineTextAlignment(.center) + } + + var body: some View { + VStack { + VStack() { + VStack(alignment: .center) { + IntroSetupsCarouselView() + + if classicOffering { + VStack { + headerText + BlinkClassicBulletPoints() + } + //.padding([.top, .bottom], 30) + .frame(maxWidth: .infinity) + } else { + VStack { + headerText + Text("Fully customizable, always-on, and ready for anything. Your entire terminal workflow, now fits in your pocket.") + .font(ctx.offeringSubheaderFont()) + .fixedSize(horizontal: false, vertical: true) + .foregroundColor(BlinkColors.blinkText) // 3 + .multilineTextAlignment(.center) + } + .padding([.top, .bottom], ctx.outterPadding()) + } + } + .padding(ctx.pagePadding()) + .background(.black) + + Rectangle() + .fill(BlinkColors.blink) + .frame(height: 2) + .padding(0) + + VStack { + Button(blinkPlusSubscribeButtonText()) { + Task { + await self.purchaseBlinkPlus() + } + }.buttonStyle(BlinkButtonStyle.cta(disabled: _purchases.restoreInProgress || _purchases.purchaseInProgress, + inProgress: _purchases.purchaseInProgress || _purchases.restoreInProgress || _purchases.formattedBlinkPlusPriceWithPeriod() == nil, minWidth: minButtonWidth)) + + if _purchases.blinkPlusIntroOfferAvailable() { + TrialSwitch(doTrialNotification: $doTrialNotification) + .disabled(_purchases.restoreInProgress || _purchases.purchaseInProgress) + } + + NewOfferingTermsButtons(ctx: ctx, purchaseCompletedHandler: purchaseCompletedHandler, urlHandler: urlHandler) + } + .padding(.top, 16) + .background(BlinkColors.bg.opacity(0.2)) // + .alert("Thank you!", isPresented: $_purchases.restoredPurchaseMessageVisible) { + Button("OK") { + self.purchaseCompletedHandler() + } + } message: { + Text(_purchases.restoredPurchaseMessage) + } + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + func blinkPlusSubscribeButtonText() -> String { + let price = _purchases.formattedPlusPriceWithPeriod()?.uppercased() ?? "" + + if _purchases.blinkPlusIntroOfferAvailable() { + return "TRY IT FREE FOR 14 DAYS" + } else { + return "BUY \(price)" + } + } + + func purchaseBlinkPlus() async { + // Restore before purchase and check entitlements, because Blink Plus may come from different groups on previous Blink+Build. + if await _purchases.restoreBlinkPlusEntitlements(alertIfNone: false) { + self.purchaseCompletedHandler() + return + } + + let trialSelection = _purchases.blinkPlusIntroOfferAvailable() ? doTrialNotification : false + let success = await _purchases.purchaseBlinkPlusWithTrialValidation(setupTrial: trialSelection) + if success { + self.purchaseCompletedHandler() + } + } +} + +struct TrialSwitch: View { + @Binding var doTrialNotification: Bool + + var body: some View { + HStack { + Spacer() + Text("Get Trial Reminder") + .foregroundColor(BlinkColors.infoText) + .font(BlinkFonts.btnSub) + Toggle("", isOn: $doTrialNotification) + .toggleStyle(.switch) + .labelsHidden() + .scaleEffect(0.7) + .tint(BlinkColors.primaryBtnBorder) + Spacer() + }.controlSize(.mini) + } +} + +struct NewIntroPageWindow: View { + let urlHandler: (URL) -> Void + let dismissHandler: () -> () + + @Environment(\.dynamicTypeSize) var dynamicTypeSize + @State var page = NewIntroPageState.offerings + @StateObject var _purchases = PurchasesUserModel.shared + + var body: some View { + GeometryReader { proxy in + let ctx = PageCtx( + proxy: proxy, + dynamicTypeSize: dynamicTypeSize + ) + + let isPad = UIDevice.current.userInterfaceIdiom == .pad + let width = proxy.size.width * (isPad ? (ctx.portrait ? 0.9 : 0.55) : 0.9) + let height = proxy.size.height * (isPad ? (ctx.portrait ? 0.7 : 0.9) : (ctx.verticalCompact ? 0.9 : 0.7)) + Group { + ZStack { +// Text("hello world") + switch page { + case .offerings: + NewOfferingsView(classicOffering: false, ctx: ctx, purchaseCompletedHandler: { page = .walkthrough }, urlHandler: urlHandler, dismissHandler: nil) + case .purchaseCompleted: + PurchaseCompletedView(ctx: ctx, walkthroughHandler: { page = .walkthrough }, dismissHandler: dismissHandler) + case .walkthrough: + WalkthroughView(ctx: ctx, urlHandler: urlHandler, dismissHandler: dismissHandler).transition(.move(edge: .trailing)).transition(.opacity) + } + } + } + .background(.black) + .clipShape(RoundedRectangle(cornerRadius: 45)) + .overlay( + RoundedRectangle(cornerRadius: 45) + .stroke(BlinkColors.blink, lineWidth: 2) + ) + .frame(width: width, height: height) + .padding(.all, ctx.outterPadding()) + .frame(width: proxy.size.width, height: proxy.size.height) + } + .alert(errorMessage: $_purchases.alertErrorMessage) + + .background(LinearGradient( + gradient: Gradient(colors: [BlinkColors.linearGradient1, Color.black]), + startPoint: .top, + endPoint: .bottom + )) + .ignoresSafeArea(.all, edges: [.bottom, .horizontal]) + } +} + +enum NewIntroPageState { + case offerings + case purchaseCompleted + case walkthrough +} + +struct NewOfferingsPreview: PreviewProvider { + static var previews: some View { + GeometryReader { proxy in + NewOfferingsPreviewWrapper(proxy: proxy) + } + } +} + +fileprivate struct NewOfferingsPreviewWrapper: View { + let proxy: GeometryProxy + @Environment(\.dynamicTypeSize) private var dynamicTypeSize // Move @Environment inside a View + + var body: some View { + let ctx = PageCtx( + proxy: proxy, + dynamicTypeSize: dynamicTypeSize + ) + return NewOfferingsView(classicOffering: true, ctx: ctx, purchaseCompletedHandler: {}, urlHandler: { _ in }, dismissHandler: {}).background(.black) + } +} + +struct NewIntroPagePreview: PreviewProvider { + static var previews: some View { +// NewIntroPageWindow(urlHandler: {_ in }, dismissHandler: { }) +// .environment(\.sizeCategory, .extraSmall) + + NewIntroPageWindow(urlHandler: {_ in }, dismissHandler: { }) + +// NewIntroPageWindow(urlHandler: {_ in }, dismissHandler: { }) +// .environment(\.sizeCategory, .accessibilityExtraLarge) + + } +} diff --git a/Blink/Subscriptions/Paywall/CheckmarkRow.swift b/Blink/Subscriptions/Paywall/CheckmarkRow.swift deleted file mode 100644 index 15b3bd66c..000000000 --- a/Blink/Subscriptions/Paywall/CheckmarkRow.swift +++ /dev/null @@ -1,76 +0,0 @@ -//////////////////////////////////////////////////////////////////////////////// -// -// B L I N K -// -// Copyright (C) 2016-2019 Blink Mobile Shell Project -// -// This file is part of Blink. -// -// Blink is free software: you can redistribute it and/or modify -// it under the terms of the GNU General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// Blink is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with Blink. If not, see . -// -// In addition, Blink is also subject to certain additional terms under -// GNU GPL version 3 section 7. -// -// You should have received a copy of these additional terms immediately -// following the terms and conditions of the GNU General Public License -// which accompanied the Blink Source Code. If not, see -// . -// -//////////////////////////////////////////////////////////////////////////////// - -import Foundation -import SwiftUI - -struct CheckmarkRow: View { - let text: String - let checked: Bool - let failed: Bool - - let failedIcon: String - let checkedIcon: String - let uncheckedIcon: String - - let iconColor: Color - let iconFailedColor: Color - - init( - text: String, - checked: Bool = true, - failed: Bool = false, - failedIcon: String = "exclamationmark.circle", - checkedIcon: String = "checkmark.circle.fill", - uncheckedIcon: String = "circle", - iconColor: Color = .green, - iconFailedColor: Color = .orange - ) { - self.text = text - self.checked = checked - self.failed = failed - self.uncheckedIcon = uncheckedIcon - self.checkedIcon = checkedIcon - self.failedIcon = failedIcon - self.iconColor = iconColor - self.iconFailedColor = iconFailedColor - } - - var body: some View { - HStack(alignment: .firstTextBaseline) { - Image(systemName: failed ? failedIcon : checked ? checkedIcon : uncheckedIcon) - .foregroundColor(failed ? iconFailedColor : iconColor) - .frame(maxWidth: 26) - Text(text).fixedSize(horizontal: false, vertical: true) - Spacer() - } - } -} diff --git a/Blink/Subscriptions/Paywall/PurchasePageView.swift b/Blink/Subscriptions/Paywall/PurchasePageView.swift deleted file mode 100644 index b65480615..000000000 --- a/Blink/Subscriptions/Paywall/PurchasePageView.swift +++ /dev/null @@ -1,146 +0,0 @@ -////////////////////////////////////////////////////////////////////////////////// -//// -//// B L I N K -//// -//// Copyright (C) 2016-2019 Blink Mobile Shell Project -//// -//// This file is part of Blink. -//// -//// Blink is free software: you can redistribute it and/or modify -//// it under the terms of the GNU General Public License as published by -//// the Free Software Foundation, either version 3 of the License, or -//// (at your option) any later version. -//// -//// Blink is distributed in the hope that it will be useful, -//// but WITHOUT ANY WARRANTY; without even the implied warranty of -//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -//// GNU General Public License for more details. -//// -//// You should have received a copy of the GNU General Public License -//// along with Blink. If not, see . -//// -//// In addition, Blink is also subject to certain additional terms under -//// GNU GPL version 3 section 7. -//// -//// You should have received a copy of these additional terms immediately -//// following the terms and conditions of the GNU General Public License -//// which accompanied the Blink Source Code. If not, see -//// . -//// -////////////////////////////////////////////////////////////////////////////////// -// -//import Foundation -//import SwiftUI -////import Spinner -// -//struct PurchasePageView: Page { -// @ObservedObject private var _model: PurchasesUserModel = .shared -// -// var horizontal: Bool -// var switchTab: (_ idx: Int) -> () -// -// init(horizontal: Bool, switchTab: @escaping (Int) -> ()) { -// self.horizontal = horizontal -// self.switchTab = switchTab -// } -// -// var body: some View { -// VStack(alignment: .leading) { -// header() -// Spacer().frame(maxHeight: horizontal ? 20 : 30) -// rows() -// Spacer().frame(maxHeight: horizontal ? 20 : 54) -// HStack { -// Spacer() -// if _model.purchaseInProgress { -// ProgressView() -// .transition(.slide) -// } else { -// if let _ = _model.plusProduct { -// Button("Subscribe now") { -// _model.purchasePlus() -// } -// .buttonStyle(.borderedProminent) -// .transition( -// .scale.combined(with: .opacity).combined(with: .slide) -// ) -// Spacer().frame(maxWidth: 40) -// } -// Button("get more free time") { -// withAnimation { -// switchTab(1) -// } -// } -// } -// Spacer() -// }.frame(minHeight: 40) -// Spacer() -// if _model.restoreInProgress { -// HStack { -// Spacer() -// ProgressView(label: { Text("restoring purchases....") }) -// Spacer() -// }.padding(.bottom, self.horizontal ? 24 : 32) -// } else { -// HStack { -// Spacer() -// if let formattedPrice = _model.formattedPlusPriceWithPeriod() { -// Text("Plan auto-renews for \(formattedPrice) until canceled.") -// .font(.footnote) -// } -// Spacer() -// } -// Spacer().frame(maxHeight:8) -// HStack { -// Spacer() -// Button("Privacy Policy", action: { -// _model.openPrivacyAndPolicy() -// }).padding(.trailing) -// Button("Terms of Use", action: { -// _model.openTermsOfUse() -// }).padding(.trailing) -// Button("Restore", action: { -// _model.restorePurchases() -// }) -// Spacer() -// } -// .font(.footnote) -// .padding(.bottom, self.horizontal ? 32 : 40) -// } -// -// }.padding() -// .frame(maxWidth: horizontal ? 700 : 460) -// } -// -// func header() -> some View { -// Group { -// Spacer() -// Text( horizontal ? "It is Time to become a pro." : "It is Time\nto become a pro.") -// .fontWeight(.bold) -// .font(.largeTitle) -// -// Spacer().frame(maxHeight: horizontal ? 24 : 30) -// -// Text("Leverage Blink’s full power, and make it your all day companion.") -// .font(.title2) -// } -// } -// -// func rows() -> some View { -// GroupBox() { -// if horizontal { -// CheckmarkRow(text: "Access to all Blink.app features and Blink+ services") -// } else { -// CheckmarkRow(text: "Access to all Blink.app features") -// } -// Spacer().frame(maxHeight: 10) -// CheckmarkRow(text: "Interruption free usage", checkedIcon: "infinity") -// Spacer().frame(maxHeight: 10) -// if !horizontal { -// CheckmarkRow(text: "Early Access Blink+ features and services", checkedIcon: "plus") -// Spacer().frame(maxHeight: 10) -// } -// CheckmarkRow(text: "Support Blink development", checkedIcon: "suit.heart.fill", iconColor: .red) -// } -// } -//} diff --git a/Blink/Subscriptions/Purchases.swift b/Blink/Subscriptions/Purchases.swift index d9bf762b5..2484e4c41 100644 --- a/Blink/Subscriptions/Purchases.swift +++ b/Blink/Subscriptions/Purchases.swift @@ -71,6 +71,8 @@ fileprivate extension EntitlementPeriodType { self = Self.Intro case .trial: self = Self.Trial + case .prepaid: + self = Self.None } } } @@ -80,11 +82,8 @@ func configureRevCat() { let cfg = Configuration .builder(withAPIKey: XCConfig.infoPlistRevCatPubliKey()) .with(appUserID: nil) - .with(observerMode: false) .with(userDefaults: UserDefaults.suite) - .with(usesStoreKit2IfAvailable: true) .build() Purchases.configure(with: cfg) - print("RevCat UserID is \(Purchases.shared.appUserID)") } diff --git a/Blink/Subscriptions/PurchasesUserModel.swift b/Blink/Subscriptions/PurchasesUserModel.swift index cc87f14a7..3411a3fb2 100644 --- a/Blink/Subscriptions/PurchasesUserModel.swift +++ b/Blink/Subscriptions/PurchasesUserModel.swift @@ -41,34 +41,32 @@ class PurchasesUserModel: ObservableObject { @Published var classicProduct: StoreProduct? = nil @Published var blinkPlusBuildBasicProduct: StoreProduct? = nil @Published var blinkPlusProduct: StoreProduct? = nil - + @Published var blinkBuildTrial: IntroEligibility? = nil @Published var blinkPlusBuildTrial: IntroEligibility? = nil - @Published var blinkPlusDiscount: IntroEligibility? = nil - + @Published var blinkPlusIntroOffer: IntroEligibility? = nil + // MARK: Progress indicators @Published var purchaseInProgress: Bool = false @Published var restoreInProgress: Bool = false - + @Published var buildBasicTrialEligibility: IntroEligibility? = nil - + @Published var restoredPurchaseMessageVisible = false @Published var restoredPurchaseMessage = "" @Published var alertErrorMessage: String = "" - + var isBuildBasicTrialEligible: Bool { self.buildBasicTrialEligibility?.status == .eligible } - private init() { - refresh() + refreshProducts() } - + static let shared = PurchasesUserModel() - - func refresh() { - BuildAccountModel.shared.checkBuildToken(animated: false) + + private func refreshProducts() { if self.blinkShellPlusProduct == nil || self.classicProduct == nil || self.buildBasicProduct == nil @@ -77,34 +75,37 @@ class PurchasesUserModel: ObservableObject { self.fetchTrialEligibility() } } - - - func purchaseBuildBasic() async { + + private func refreshTokens() { + BuildAccountModel.shared.checkBuildToken(animated: false) + } + + func purchaseBuildBasic() async { guard let product = buildBasicProduct else { self.alertErrorMessage = "Product should be loaded" return } - + guard PublishingOptions.current.contains(.appStore) else { self.alertErrorMessage = "Available only in App Store" return } - + withAnimation { self.purchaseInProgress = true } - + defer { - self.refresh() + BuildAccountModel.shared.checkBuildToken(animated: false) self.purchaseInProgress = false } - + do { let (_, _, canceled) = try await Purchases.shared.purchase(product: product) if canceled { return } - + await BuildAccountModel.shared.trySignIn() withAnimation { self.purchaseInProgress = false @@ -113,147 +114,175 @@ class PurchasesUserModel: ObservableObject { self.alertErrorMessage = error.localizedDescription } } - - func purchaseBlinkPlusBuildWithValidation(setupTrial: Bool) async { - await _purchaseWithValidation(product: blinkPlusBuildBasicProduct, setupTrial: setupTrial) - } - - func purchaseBlinkShellPlusWithValidation() async { - await _purchaseWithValidation(product: blinkShellPlusProduct) - } - - func purchaseBlinkPlusWithValidation() async { - await _purchaseWithValidation(product: blinkPlusProduct) - } - func purchaseClassic() { - _purchase(product: classicProduct) + func purchaseBlinkPlusWithTrialValidation(setupTrial: Bool) async -> Bool { + let duration: TrialDuration = setupTrial ? .twoWeeks : .no + + return await _purchaseWithTrialValidation(product: blinkPlusProduct, setupTrialDuration: duration) } - + + // func purchaseClassic() { + // _purchase(classicProduct) + // } + func buildTrialAvailable() -> Bool { self.blinkBuildTrial?.status == IntroEligibilityStatus.eligible } - + func blinkPlusBuildTrialAvailable() -> Bool { blinkPlusBuildTrial?.status == IntroEligibilityStatus.eligible } + func blinkPlusIntroOfferAvailable() -> Bool { + blinkPlusIntroOffer?.status == IntroEligibilityStatus.eligible + } + func getUserID() -> String { Purchases.shared.appUserID } - - private func _purchase(product: StoreProduct?) { + + private func _purchase(_ product: StoreProduct) async -> Bool { + do { + let result = try await Purchases.shared.purchase(product: product) + if result.userCancelled { + return false + } + return true + } catch { + self.alertErrorMessage = "Could not continue with purchase - \(error.localizedDescription)" + return false + } + } + + private func _setupTrialProgressNotification(_ progress: TrialProgressNotification) async -> Bool { + do { + let notificationsAccepted = try await progress.setup() + + if !notificationsAccepted { + self.alertErrorMessage = "To continue, please accept or disable notifications for trial conversion." + return false + } + + return true + } catch { + self.alertErrorMessage = "Could not enable notifications - \(error.localizedDescription)" + return false + } + } + + private func _purchaseWithTrialValidation(product: StoreProduct?, setupTrialDuration: TrialDuration = .no) async -> Bool { guard let product = product else { - return + self.alertErrorMessage = "No valid products selected" + return false } + withAnimation { self.purchaseInProgress = true } - - Purchases.shared.purchase(product: product) { (transaction, purchaseInfo, error, cancelled) in - self.refresh() + + defer { self.purchaseInProgress = false } + + if let notification: TrialProgressNotification = switch setupTrialDuration { + case .no: + nil + case .oneWeek: + TrialProgressNotification.OneWeek + case .twoWeeks: + TrialProgressNotification.TwoWeeks + case .oneMonth: + TrialProgressNotification.OneMonth + } { + let success = await _setupTrialProgressNotification(notification) + if !success { + return false + } + } + + return await _purchase(product) } - private func _purchaseWithValidation(product: StoreProduct?, setupTrial: Bool = false) async { -// _purchase(product: product) - if setupTrial { - do { - var notificationsAccepted = false - switch product?.productIdentifier { - case ProductBlinkPlusBuildBasicID: - notificationsAccepted = try await TrialProgressNotification.OneWeek.setup() - default: - break - } - - if !notificationsAccepted { - EntitlementsManager.shared.keepShowingPaywall = false - self.purchaseInProgress = false - self.alertErrorMessage = "To continue, please accept or disable notifications for trial conversion." - return - } - } catch { - EntitlementsManager.shared.keepShowingPaywall = false - self.purchaseInProgress = false - self.alertErrorMessage = "Could not enable notifications - \(error.localizedDescription)" - return + func restoreActiveAppSubscriptions(alertIfNone: Bool) async -> Bool { + await _restorePurchases() + + if EntitlementsManager.shared.hasActiveSubscriptions() { + self.restoredPurchaseMessage = "We have restored your subscriptions. Thanks for your support!" + self.restoredPurchaseMessageVisible = true + return true + } else { + if alertIfNone { + self.alertErrorMessage = "Could not find an active subscription. Please contact us if you are having trouble." } + return false } + } + + func restoreBlinkPlusEntitlements(alertIfNone: Bool) async -> Bool { + await _restorePurchases() - do { - self.purchaseInProgress = true - EntitlementsManager.shared.keepShowingPaywall = true - let res = try await Purchases.shared.restorePurchases() - - if EntitlementsManager.shared.build.active { - await BuildAccountModel.shared.trySignIn(); - } - - if res.activeSubscriptions.contains(ProductBlinkShellPlusID) { - self.restoredPurchaseMessage = "We have restored your subscription to Blink+.\nThanks for your support!" - self.restoredPurchaseMessageVisible = true - self.purchaseInProgress = false - return + if EntitlementsManager.shared.earlyAccessFeatures.active, + EntitlementsManager.shared.unlimitedTimeAccess.active { + self.restoredPurchaseMessage = "We have restored your subscriptions. Thanks for your support!" + self.restoredPurchaseMessageVisible = true + return true + } else { + if alertIfNone { + self.alertErrorMessage = "Could not find a valid purchase for Blink Plus." } - if res.activeSubscriptions.contains(ProductBlinkPlusBuildBasicID) { - self.restoredPurchaseMessage = "We have restored your subscription to Blink+Build.\nThanks for your support!" - self.restoredPurchaseMessageVisible = true - self.purchaseInProgress = false - return + return false + } + } + + func restoreBlinkBuildEntitlements(alertIfNone: Bool) async -> Bool { + await _restorePurchases() + + if EntitlementsManager.shared.build.active { + self.restoredPurchaseMessage = "We have restored your subscriptions. Thanks for your support!" + self.restoredPurchaseMessageVisible = true + return true + } else { + if alertIfNone { + self.alertErrorMessage = "Could not find Blink Build entitlements in your subscription." } - } catch { - if let error = error as? RevenueCat.ErrorCode, - error == .missingReceiptFileError { - // Ignore the error and continue with purchase - print("Missing Receipt File Error - continue with purchase") - } else { - EntitlementsManager.shared.keepShowingPaywall = false - self.purchaseInProgress = false - self.alertErrorMessage = error.localizedDescription - return - } + return false } - - EntitlementsManager.shared.keepShowingPaywall = false - _purchase(product: product) } - func restorePurchases() { + private func _restorePurchases() async { self.restoreInProgress = true - EntitlementsManager.shared.keepShowingPaywall = false - Purchases.shared.restorePurchases(completion: { info, error in - self.refresh() + + defer { + self.refreshTokens() self.restoreInProgress = false - if let error { - self.alertErrorMessage = error.localizedDescription - return - } - + } + + do { + let _ = try await Purchases.shared.restorePurchases() + if EntitlementsManager.shared.build.active { - Task { - await BuildAccountModel.shared.trySignIn(); - } + await BuildAccountModel.shared.trySignIn() } - }) + } catch { + self.alertErrorMessage = error.localizedDescription + } } - + func formattedPlusPriceWithPeriod() -> String? { blinkShellPlusProduct?.formattedPriceWithPeriod() } - + func formattedBuildPriceWithPeriod() -> String? { buildBasicProduct?.formattedPriceWithPeriod() } - + func formattedBlinkPlusBuildPriceWithPeriod() -> String? { blinkPlusBuildBasicProduct?.formattedPriceWithPeriod() } - + func formattedBlinkPlusPriceWithPeriod() -> String? { blinkPlusProduct?.formattedPriceWithPeriod() } - - func fetchProducts() { + + private func fetchProducts() { Purchases.shared.getProducts([ ProductBlinkShellClassicID, ProductBlinkShellPlusID, @@ -264,7 +293,7 @@ class PurchasesUserModel: ObservableObject { DispatchQueue.main.async { for product in products { let productID = product.productIdentifier - + if productID == ProductBlinkShellPlusID { self.blinkShellPlusProduct = product } else if productID == ProductBlinkShellClassicID { @@ -280,30 +309,33 @@ class PurchasesUserModel: ObservableObject { } } } - - func fetchTrialEligibility() { + + private func fetchTrialEligibility() { Purchases.shared.checkTrialOrIntroDiscountEligibility( productIdentifiers: [ ProductBlinkBuildBasicID, ProductBlinkPlusBuildBasicID, - ProductBlinkPlusID], + ProductBlinkPlusID + ], completion: { map in DispatchQueue.main.async { self.blinkBuildTrial = map[ProductBlinkBuildBasicID] self.blinkPlusBuildTrial = map[ProductBlinkPlusBuildBasicID] - self.blinkPlusDiscount = map[ProductBlinkPlusID] + self.blinkPlusIntroOffer = map[ProductBlinkPlusID] } }) } - + private lazy var _emailPredicate: NSPredicate = { let emailRegEx = "[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,64}" return NSPredicate(format:"SELF MATCHES %@", emailRegEx) }() - - enum MigrationStatus { - case validating, accepted - case denied(error: Error) + + private enum TrialDuration { + case no + case oneWeek + case twoWeeks + case oneMonth } } @@ -312,15 +344,15 @@ extension PurchasesUserModel { func openPrivacyAndPolicy() { blink_openurl(URL(string: "https://blink.sh/pp")!) } - + func openTermsOfUse() { blink_openurl(URL(string: "https://blink.sh/blink-gpl")!) } - + func openHelp() { blink_openurl(URL(string: "https://blink.sh/docs")!) } - + func openMigrationHelp() { blink_openurl(URL(string: "https://docs.blink.sh/migration")!) } @@ -400,7 +432,7 @@ extension StoreProduct { @objc public class PurchasesUserModelObjc: NSObject { - + @objc public static func preparePurchasesUserModel() { configureRevCat() EntitlementsManager.shared.startUpdates() @@ -414,9 +446,9 @@ extension Bundle { FileManager.default.fileExists(atPath: appStoreReceiptURL.path) else { return nil } - + let receiptData = try? Data(contentsOf: appStoreReceiptURL, options: .alwaysMapped) - + return receiptData?.base64EncodedString(options: []) } } diff --git a/Blink/Subscriptions/TrialNotification.swift b/Blink/Subscriptions/TrialNotification.swift index 6fc2d1630..cc3d789bb 100644 --- a/Blink/Subscriptions/TrialNotification.swift +++ b/Blink/Subscriptions/TrialNotification.swift @@ -38,6 +38,7 @@ fileprivate let trialSupportRequestID = "trial-support-request" enum TrialProgressNotification { case OneWeek + case TwoWeeks case OneMonth } @@ -56,15 +57,24 @@ extension TrialProgressNotification { let content = UNMutableNotificationContent() content.title = "Hope you are enjoying Blink." - var dateComponents = DateComponents() + var notifyAfterDays: Int switch self { case .OneWeek: - dateComponents.day = 5 + notifyAfterDays = 7 - 2 content.body = "Your trial will convert in 2 days." + case .TwoWeeks: + notifyAfterDays = 14 - 3 + content.body = "Your trial will convert in 3 days." case .OneMonth: - dateComponents.day = 23 + notifyAfterDays = 30 - 7 content.body = "Your trial will convert in 7 days." } + + let targetDate = Calendar.current.date(byAdding: .day, value: notifyAfterDays, to: Date())! + var dateComponents = Calendar.current.dateComponents([.year, .month, .day], from: targetDate) + dateComponents.hour = 12 + dateComponents.minute = 0 + let trigger = UNCalendarNotificationTrigger(dateMatching: dateComponents, repeats: false) @@ -78,17 +88,27 @@ extension TrialProgressNotification { private func scheduleTrialSupportRequestNotification() async throws { let content = UNMutableNotificationContent() - var dateComponents = DateComponents() + var notifyAfterDays: Int switch self { case .OneWeek: - dateComponents.day = 3 + notifyAfterDays = 3 content.title = "You are in day 3 of your trial..." - content.body = "And we are here to help setting things up. Type `config` on the shell and ask us!" + content.body = "And we are here to help setting things up. Type `config` on the shell and ask!" + case .TwoWeeks: + notifyAfterDays = 5 + content.title = "You are in day 5 of your trial..." + content.body = "And we are here to help setting things up. Type `config` on the shell and ask!" case .OneMonth: - dateComponents.day = 7 + notifyAfterDays = 7 content.title = "You are in day 7 of your trial..." - content.body = "And we are here to help setting things up. Type `config` on the shell and ask us!" + content.body = "And we are here to help setting things up. Type `config` on the shell and ask!" } + + let targetDate = Calendar.current.date(byAdding: .day, value: notifyAfterDays, to: Date())! + var dateComponents = Calendar.current.dateComponents([.year, .month, .day], from: targetDate) + dateComponents.hour = 12 + dateComponents.minute = 0 + let trigger = UNCalendarNotificationTrigger(dateMatching: dateComponents, repeats: false) @@ -97,6 +117,5 @@ extension TrialProgressNotification { trigger: trigger) try await unc.add(request) - } } diff --git a/Blink/Subscriptions/TypingText.swift b/Blink/Subscriptions/TypingText.swift new file mode 100644 index 000000000..79d51d3fb --- /dev/null +++ b/Blink/Subscriptions/TypingText.swift @@ -0,0 +1,108 @@ +////////////////////////////////////////////////////////////////////////////////// +// +// B L I N K +// +// Copyright (C) 2016-2025 Blink Mobile Shell Project +// +// This file is part of Blink. +// +// Blink is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Blink is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Blink. If not, see . +// +// In addition, Blink is also subject to certain additional terms under +// GNU GPL version 3 section 7. +// +// You should have received a copy of these additional terms immediately +// following the terms and conditions of the GNU General Public License +// which accompanied the Blink Source Code. If not, see +// . +// +//////////////////////////////////////////////////////////////////////////////// + + +import SwiftUI + +struct TypingText: View { + let fullText: String + let cursor: String + var onFinished: (() -> Void)? = nil + var style: (Text) -> Text + + @State private var displayedText = "" + @State private var isTypingFinished = false + @State private var showCursor = true + + + init(fullText: String, cursor: String, onFinished: (() -> Void)? = nil, style: @escaping (Text) -> Text = { $0 }) { + self.fullText = fullText + self.cursor = cursor + self.onFinished = onFinished + self.style = style + } + + var body: some View { + ZStack(alignment: .topLeading) { + style(Text(fullText + cursor)) // layout reservation + .hidden() + + style(Text(attributedStringWithCursor)) // visible typing + } + .onAppear { + typeNextCharacter(index: 0) + startCursorBlink() + } + } + + private var attributedStringWithCursor: AttributedString { + var result = AttributedString(displayedText + cursor) + + if isTypingFinished && !showCursor { + if let cursorRange = result.range(of: cursor, options: .backwards) { + result[cursorRange].foregroundColor = .clear + } + } + + return result + } + + private func typeNextCharacter(index: Int) { + guard index < fullText.count else { + isTypingFinished = true + onFinished?() + return + } + + let delay = Double.random(in: 0.03...0.12) + + DispatchQueue.main.asyncAfter(deadline: .now() + delay) { + displayedText.append(fullText[fullText.index(fullText.startIndex, offsetBy: index)]) + typeNextCharacter(index: index + 1) + } + } + + private func startCursorBlink() { + Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { _ in + if isTypingFinished { + showCursor.toggle() + } + } + } +} + +#Preview { + TypingText(fullText: "Hello, this is a typing effect.", + cursor: "|", onFinished: {}) { text in + text.font(.system(size: 20, weight: .medium, design: .monospaced)) + .foregroundColor(.green) + } +} diff --git a/Blink/Subscriptions/Walkthrough.swift b/Blink/Subscriptions/Walkthrough.swift new file mode 100644 index 000000000..0da2bed7a --- /dev/null +++ b/Blink/Subscriptions/Walkthrough.swift @@ -0,0 +1,277 @@ +////////////////////////////////////////////////////////////////////////////////// +// +// B L I N K +// +// Copyright (C) 2016-2025 Blink Mobile Shell Project +// +// This file is part of Blink. +// +// Blink is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Blink is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Blink. If not, see . +// +// In addition, Blink is also subject to certain additional terms under +// GNU GPL version 3 section 7. +// +// You should have received a copy of these additional terms immediately +// following the terms and conditions of the GNU General Public License +// which accompanied the Blink Source Code. If not, see +// . +// +//////////////////////////////////////////////////////////////////////////////// + + +import SwiftUI + +struct PageInfo: Identifiable { + let title: String + let info: Text + let image: String + let verticalImage: String? + let imageMaxSize: CGSize + let url: URL + let linkText: Text + let compactInfo: Text + + var id: String { title } + + init(title: String, linkText: Text, url: URL, info: Text, compactInfo: Text, image: String, imageMaxSize: CGSize = CGSize(width: 700, height: 450)) { + self.title = title + self.linkText = linkText + self.url = url + self.info = info + self.compactInfo = compactInfo + self.image = image + self.verticalImage = nil + self.imageMaxSize = imageMaxSize + } + + init(title: String, linkText: Text, url: URL, info: Text, compactInfo: Text, image: String, verticalImage: String, imageMaxSize: CGSize = CGSize(width: 700, height: 450)) { + self.title = title + self.linkText = linkText + self.url = url + self.info = info + self.compactInfo = compactInfo + self.image = image + self.verticalImage = verticalImage + self.imageMaxSize = imageMaxSize + } + + // TODO Don't like having full fields hanging here. Maybe just "Strings" + static let multipleTerminalsInfo = PageInfo( + title: "MULTIPLE TERMINALS & WINDOWS", + linkText: Text("READ DOCS"), + url: URL(string: "https://docs.blink.sh/basics/navigation")!, + info: Text("Use **pinch** to zoom. **Two finger tap** to create a new shell. **Slide** to move between shells. **Double tap ⌘ or Home bar** for menu.\nType **help** if you need it."), + compactInfo: Text("Not to use in compact"), + image: "intro-windows" + + ) + + static let hostsKeysEverywhereInfo = PageInfo( + title: "YOUR HOSTS & KEYS, EVERYWHERE", + linkText: Text("READ DOCS"), + url: URL(string: "https://docs.blink.sh/basics/hosts")!, + info: Text("Type **`config`** for configuration. Use **Hosts** and **Keys** to setup remote connections. **Keyboard** for modifiers and shortcuts. **Appearance** for fonts and themes."), + compactInfo: Text("Not to use in compact"), + image: "intro-settings" + ) + + static let sshMoshToolsInfo = PageInfo( + title: "SSH, MOSH & BASIC TOOLS", + linkText: Text("\(Image(systemName: "play.rectangle.fill")) WATCH"), + url: URL(string: "https://youtube.com/shorts/VYmrSlG9lX0")!, + info: Text("Type **`mosh`** for high-performance remote shells. Type **`ssh`** for secure shells and tunnels. Type **`sftp`** or **`scp`** for secure file transfer. Use **tab** to list tools like **`vim`**, **`ping`**, etc..."), + compactInfo: Text("SSH & Mosh • Secure Keys, Certificates & HW • Jump Hosts • Agent • SFTP"), + image: "intro-commands" + ) + + static let blinkCodeInfo = PageInfo( + title: "BLINK CODE, YOUR NEW SUPERPOWER", + linkText: Text("READ DOCS"), + url: URL(string: "https://docs.blink.sh/advanced/code")!, + info: Text("Use **`code`** for VS Code editor capabilities. Edit local files, remote files, and even connect to GitHub Codespaces, GitPod or others. All within a first class iOS experience adapted to your device."), + compactInfo: Text("Edit local files • Edit remote files • Interface adapted to your mobile device"), + image: "intro-code", + imageMaxSize: CGSize(width: 680, height: 400) + ) + + static let blinkBuildInfo = PageInfo( + title: "BUILD YOUR DEV ENVIRONMENTS", + linkText: Text("\(Image(systemName: "play.rectangle.fill")) WATCH"), + url: URL(string: "https://youtu.be/78XukJvz5vg")!, + info: Text("Use **`build`** to access instant dev environments for any task. Use our default Hacker Tools container for coding on Python, JS, Go, Rust, C, etc... Connect your containers to run any application."), + compactInfo: Text("Run Python, Go, Rust, and others •\u{00a0}2\u{00a0}vCPU •\u{00a0}4\u{00a0}GB\u{00a0}RAM •\u{00a0}50\u{00a0}hours/month"), + image: "intro-build-horizontal", + verticalImage: "intro-build-vertical" + ) +} + +struct WalkthroughProgressButtons: View { + let ctx: PageCtx + let url: URL + let text: Text + let urlHandler: (URL) -> () + let dismissHandler: () -> () + + var body: some View { + HStack { + Button( + action: { urlHandler(url) }, + label: { text } + ) + .buttonStyle(BlinkButtonStyle.secondary(disabled: false, inProgress: false)) + Spacer().frame(width: 20) + + Button("GO TO SHELL") { + dismissHandler() + }.buttonStyle(BlinkButtonStyle.primary(disabled: false, inProgress: false)) + } + .padding(.bottom, ctx.portrait ? 26 : 0) + } +} + +struct WalkthroughTabViewControls: View { + @Binding var pageIndex: Int + let firstPageIndex: Int + let lastPageIndex: Int + + var body: some View { + HStack { + Button { + if self.pageIndex > self.firstPageIndex { + withAnimation { + self.pageIndex -= 1 + } + } + } label: { + Image(systemName: "chevron.compact.left").font(.title).foregroundColor(BlinkColors.code) + .padding() + } + .opacity(pageIndex == self.firstPageIndex ? 0.3 : 1.0).disabled(pageIndex == self.firstPageIndex) + .hoverEffect(.highlight) + .keyboardShortcut(.leftArrow) + Spacer() + Button { + if self.pageIndex < lastPageIndex { + withAnimation { + self.pageIndex += 1 + } + } + } label: { + Image(systemName: "chevron.compact.right").font(.title).foregroundColor(BlinkColors.code) + .padding() + } + .opacity(pageIndex == lastPageIndex ? 0.3 : 1.0).disabled(pageIndex == lastPageIndex) + .hoverEffect(.highlight) + .keyboardShortcut(.rightArrow) + + } + } +} + +struct WalkthroughPageView: View { + let ctx: PageCtx + let info: PageInfo + let urlHandler: (URL) -> () + let dismissHandler: () -> () + + var body: some View { + VStack { + Text(info.title) + .font(ctx.headerFont()) + .foregroundColor(BlinkColors.headerText) + .multilineTextAlignment(.center) + Spacer() + Image(ctx.portrait ? info.verticalImage ?? info.image : info.image) + .resizable() + .scaledToFit() + .frame(maxWidth: info.imageMaxSize.width , maxHeight: info.imageMaxSize.height) + .padding() + Spacer() + info.info + .font(ctx.infoFont()) + .multilineTextAlignment(.center) + .foregroundColor(BlinkColors.infoText) + .frame(maxWidth: 810) + .padding(.bottom) + Spacer() + WalkthroughProgressButtons(ctx: ctx, url: info.url, text: info.linkText, + urlHandler: urlHandler, dismissHandler: dismissHandler) + }.padding(ctx.pagePadding()) + } +} + +struct WalkthroughView: View { + let ctx: PageCtx + let urlHandler: (URL) -> () + let dismissHandler: () -> () + + @Environment(\.dynamicTypeSize) var dynamicTypeSize + let pages: [PageInfo] = [ + PageInfo.multipleTerminalsInfo, + PageInfo.hostsKeysEverywhereInfo, + PageInfo.sshMoshToolsInfo, + PageInfo.blinkCodeInfo, + PageInfo.blinkBuildInfo + ] + + @StateObject var _purchases = PurchasesUserModel.shared + @StateObject var _entitlements = EntitlementsManager.shared + + @State var pageIndex = 0 + + init(ctx: PageCtx, urlHandler: @escaping (URL) -> Void, dismissHandler: @escaping () -> Void) { + self.ctx = ctx + self.urlHandler = urlHandler + self.dismissHandler = dismissHandler + } + + var body: some View { + TabView(selection: $pageIndex) { + ForEach(Array(zip(pages.indices, pages)), id: \.0) { index, info in + WalkthroughPageView(ctx: ctx, info: info, urlHandler: urlHandler, dismissHandler: dismissHandler).tag(index) + } + } + .tabViewStyle(.page(indexDisplayMode: ctx.portrait ? .always : .never)) + .overlay( + HStack { + if !ctx.portrait { + WalkthroughTabViewControls(pageIndex: $pageIndex, firstPageIndex: 0, lastPageIndex: pages.count - 1) + } + } + ) + .background(.black) + } +} + +struct WalkthroughsPreview: PreviewProvider { + static var previews: some View { + GeometryReader { proxy in + WalkthroughPreviewWrapper(proxy: proxy) + } + } +} + +fileprivate struct WalkthroughPreviewWrapper: View { + let proxy: GeometryProxy + @Environment(\.dynamicTypeSize) private var dynamicTypeSize // Move @Environment inside a View + + var body: some View { + let ctx = PageCtx( + proxy: proxy, + dynamicTypeSize: dynamicTypeSize + ) + return WalkthroughView(ctx: ctx, urlHandler: { _ in }, dismissHandler: {}) + } +} + diff --git a/Blink/TermController.swift b/Blink/TermController.swift index 6c5f06fc9..b33b6c332 100644 --- a/Blink/TermController.swift +++ b/Blink/TermController.swift @@ -236,11 +236,6 @@ class TermController: UIViewController { view.setNeedsLayout() } - override func viewWillAppear(_ animated: Bool) { - super.viewWillAppear(animated); - resumeIfNeeded() - } - public override func viewWillLayoutSubviews() { super.viewWillLayoutSubviews() @@ -437,12 +432,11 @@ extension TermController: TermDeviceDelegate { _termDevice.input?.reset() } - public func handleControl(_ control: String!) -> Bool { - return _session?.handleControl(control) ?? false + public func handleControl(_ control: String!) -> Void { + _session?.handleControl(control) } public func deviceFocused() { - _session?.setActiveSession() view.setNeedsLayout() } @@ -496,6 +490,10 @@ extension TermController: SuspendableSession { if view.bounds.size != _sessionParams.viewSize { _session?.sigwinch() } + + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + self._termView.setClipboardWrite(true) + } } @@ -518,7 +516,9 @@ extension TermController: SuspendableSession { _session?.sigwinch() } - _termView.setClipboardWrite(true) + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + self._termView.setClipboardWrite(true) + } } func suspendedSession(with archiver: NSKeyedArchiver) { diff --git a/Blink/TermDevice.h b/Blink/TermDevice.h index 5a03c6a62..3e045d23a 100644 --- a/Blink/TermDevice.h +++ b/Blink/TermDevice.h @@ -52,7 +52,7 @@ - (void)deviceIsReady; - (void)deviceSizeChanged; - (void)viewFontSizeChanged:(NSInteger)size; -- (BOOL)handleControl:(NSString *)control; +- (void)handleControl:(NSString *)control; - (void)lineSubmitted:(NSString *)line; - (void)deviceFocused; - (void)apiCall:(NSString *)api andRequest:(NSString *)request; @@ -72,10 +72,14 @@ @property (readonly) UIView *input; @property id delegate; @property (nonatomic) BOOL rawMode; +@property (nonatomic) BOOL autoCR; @property (nonatomic) BOOL secureTextEntry; @property (nonatomic) NSInteger rows; @property (nonatomic) NSInteger cols; +// Offer the pointer as it is a struct on itself. This is helpful because on Swift, +// we cannot used a synthesized expression to get the UnsafeMutablePointer. +- (struct winsize *)window; - (void)attachInput:(UIView *)termInput; - (void)attachView:(TermView *)termView; @@ -94,4 +98,8 @@ - (void)writeOutLn:(NSString *)output; - (void)close; + +@end + +@interface TermDevice () @end diff --git a/Blink/TermDevice.m b/Blink/TermDevice.m index fcbfab132..384490129 100644 --- a/Blink/TermDevice.m +++ b/Blink/TermDevice.m @@ -142,9 +142,6 @@ - (void) close { @end -@interface TermDevice () -@end - // The TermStream is the PTYDevice // They might actually be different. The Device listens, the stream is lower level. @@ -200,13 +197,47 @@ - (id)init return self; } +- (struct winsize *)window +{ + return &win; +} + - (void)write:(NSString *)input { - if (!_rawMode) { - [self.view processKB:input]; + NSString *ctrlC = @"\x03"; + NSString *ctrlD = @"\x04"; + + if (_rawMode) { + [self writeInDirectly: input]; + return; + } + + // Cook + if ([input isEqualToString:ctrlC] || [input isEqualToString:ctrlD]) { + // [self closeReadline]; + + [self _EOT]; + //if (_readlineSema) { + if ([input isEqualToString: ctrlC]) { + fprintf(_stream.err, "^C\n"); + } + if ([input isEqualToString: ctrlD]) { + fprintf(_stream.err, "^D\n"); + } + //} + // NOTE This should send specific signals instead of handling the control openly, but won't change for now. + [self.delegate handleControl: input]; + return; + } + + // Ignore some C0 Control codes - https://wezfurlong.org/wezterm/escape-sequences.html#c0-control-codes + NSArray *ignoredSequences = [NSArray arrayWithObjects:@"\x1c", @"\x1d", @"\x1e", @"\x1f", nil]; + if ([ignoredSequences containsObject:input]) { return; } - [self writeInDirectly: input]; + + // On Blink prompt, atm this is a special mode as it doesn't have a "readline" per-se. + [self.view processKB:input]; } - (void)writeInDirectly:(NSString *)input @@ -231,7 +262,7 @@ - (void)writeOutLn:(NSString *)output { - (void)close { - // TODO: Closing the streams!! But they are duplicated!!!! + // Closing the Device streams. These are the main device, usually duped in Sessions. [_stream close]; [_outStream close]; [_errStream close]; @@ -256,14 +287,24 @@ - (void)setRawMode:(BOOL)rawMode { if (_stream.out) { if (rawMode) { - fprintf(_stream.out, "\x1b]1337;BlinkAutoCR=0\x07"); + [self setAutoCR: FALSE]; } else { - fprintf(_stream.out, "\x1b]1337;BlinkAutoCR=1\x07"); + [self setAutoCR: TRUE]; } } _rawMode = rawMode; } +- (void)setAutoCR:(BOOL)autoCR { + if (autoCR) { + fprintf(_stream.out, "\x1b]1337;BlinkAutoCR=1\x07"); + } else { + fprintf(_stream.out, "\x1b]1337;BlinkAutoCR=0\x07"); + } + + _autoCR = autoCR; +} + - (void)prompt:(NSString *)prompt secure:(BOOL)secure shell:(BOOL)shell { [self closeReadline]; @@ -300,6 +341,19 @@ - (void)closeReadline { } } +- (void)_EOT { + // On EOT, a PTY on the kernel would release linereads, etc... without closing the stream. + // We do not have that kind of access, so we "simulate" it by recycling stdin. Then we give an opportunity + // through the Terminal Delegate so sessions can "restore" stdin and continue reading from it if necessary. + // TODO We should return the last input we have from the device, but not easy to do atm. + [self closeReadline]; + close(_pinput[1]); + close(_pinput[0]); + pipe(_pinput); + _stream.in = fdopen(_pinput[0], "rb"); + setvbuf(_stream.in, NULL, _IONBF, 0); +} + - (void)setSecureTextEntry:(BOOL)secureTextEntry { _secureTextEntry = secureTextEntry; @@ -432,11 +486,6 @@ - (void)viewSelectionChanged { [_input setHasSelection:_view.hasSelection]; } -- (BOOL)handleControl:(NSString *)control -{ - return NO; -} - - (void)viewShowAlert:(NSString *)title andMessage:(NSString *)message { UIAlertController *alertController = [UIAlertController alertControllerWithTitle:title message:message diff --git a/Blink/TermStream.h b/Blink/TermStream.h index 60a73f3b0..712a18b8e 100644 --- a/Blink/TermStream.h +++ b/Blink/TermStream.h @@ -37,6 +37,7 @@ @property FILE *out; @property FILE *err; +- (FILE*)openTTY; - (void)close; - (void)closeIn; diff --git a/Blink/TermStream.m b/Blink/TermStream.m index d392557d0..e01c396b9 100644 --- a/Blink/TermStream.m +++ b/Blink/TermStream.m @@ -48,6 +48,12 @@ - (void)close { } } +// We are not a TTY, but the closest is to read directly from stdin as we offer it, without +// intermediaries (except the terminal itself). +- (FILE*)openTTY { + return fdopen(dup(fileno(_in)), "rb"); +} + - (void)closeIn { if (_in) { fflush(_in); diff --git a/Blink/TermView.h b/Blink/TermView.h index 828ff2454..49b49a2af 100644 --- a/Blink/TermView.h +++ b/Blink/TermView.h @@ -44,7 +44,6 @@ extern NSString * TermViewBrowserReadyNotificationKey; @property BOOL rawMode; -- (BOOL)handleControl:(NSString *)control; - (void)viewIsReady; - (void)viewFontSizeChanged:(NSInteger)size; - (void)viewWinSizeChanged:(struct winsize)win; diff --git a/Blink/WhatsNew/WhatsNewInfo.swift b/Blink/WhatsNew/WhatsNewInfo.swift index b02e2473a..2efb87e02 100644 --- a/Blink/WhatsNew/WhatsNewInfo.swift +++ b/Blink/WhatsNew/WhatsNewInfo.swift @@ -38,7 +38,9 @@ class WhatsNewInfo { static private let defaults = UserDefaults.standard static private let MaxDisplayCount = 5 static private let LastVersionKey = "LastVersionDisplay" + static private var LastVersion: String? { defaults.string(forKey: LastVersionKey) } static private let CountVersionDisplayKey = "CountVersionDisplayKey" + static private var CountVersionDisplay: Int? { defaults.integer(forKey: CountVersionDisplayKey) } static private var Version: String { UIApplication.blinkMajorVersion() } static private var prompt: String { "\u{1B}[30;48;5;45m New Blink \(Version)! \u{1B}[0m\u{1B}[38;5;45m\u{1B}[0m Check \"whatsnew\"" @@ -50,49 +52,66 @@ ssh, mosh - Connect to remote code - Code session build - Build dev environments config - Hosts, keys, keyboard, etc... + - Display list of commands help - Quick help """ } + // static private let BlinkClassicUpdatedDisplayKey = "BlinkClassicUpdatedDisplayKey" + // static private var BlinkClassicUpdatedDisplay: String { defaults.string(forKey: BlinkClassicUpdatedDisplayKey) } + // static private let BlinkClassicVersion = "18.2" private init() {} - + static func mustDisplayInitialPrompt() -> String? { if isFirstInstall() { promptDisplayed() return firstUsagePrompt } - + if mustDisplayVersionPrompt() { promptDisplayed() return prompt } - + return nil } - + + // static func mustDisplayBlinkClassicAlert() -> Bool { + // if let lastUpdate = BlinkClassicUpdatedDisplay { + // if versionsAreEqualIgnoringPatch(lastUpdate, BlinkClassicVersion) { + // return false + // } + // } + + // return false + // } + + // static func blinkClassicAlert() -> UIAlertController { + // let alert = UIAlertController(title: "Blink Classic plan", message: "Your Blink Classic has been updated", preferredStyle: .alert) + // alert.addAction(UIAlertAction(title: "OK")) + // alert.addAction(UIAlertAction(title: "Update to Blink+")) + // } + static func setNewVersion() { defaults.set(Version, forKey: LastVersionKey) defaults.set(0, forKey: CountVersionDisplayKey) } - + static func isFirstInstall() -> Bool { - defaults.value(forKey: LastVersionKey) == nil ? true : false + Self.LastVersion == nil ? true : false } static private func mustDisplayVersionPrompt() -> Bool { -// return true let version = Version - //defaults.set("", forKey: LastVersionKey) - //defaults.set(0, forKey: CountVersionDisplayKey) - let displayCount = defaults.integer(forKey: CountVersionDisplayKey) - if let lastVersion = defaults.string(forKey: LastVersionKey) { + if let lastVersion = Self.LastVersion, + let displayCount = Self.CountVersionDisplay { return (displayCount < MaxDisplayCount) && !versionsAreEqualIgnoringPatch(v1: version, v2: lastVersion) } else { return true } } - + static private func versionsAreEqualIgnoringPatch(v1: String, v2: String) -> Bool { v1.split(separator: ".").prefix(upTo: 2) == v2.split(separator: ".").prefix(upTo: 2) } diff --git a/BlinkCode/CodeFileSystem.swift b/BlinkCode/CodeFileSystem.swift index c42cc8d9c..6236dd66d 100644 --- a/BlinkCode/CodeFileSystem.swift +++ b/BlinkCode/CodeFileSystem.swift @@ -164,7 +164,7 @@ class CodeFileSystem { if !(options.create ?? false) { return .fail(error: CodeFileSystemError.fileNotFound(uri: self.uri)) } - return parentT.create(name: fileName, flags: O_WRONLY, mode: 0o644) + return parentT.create(name: fileName, mode: 0o644) } // 2. Write the content to the file .flatMap { file -> AnyPublisher in diff --git a/BlinkConfig/BKHosts.h b/BlinkConfig/BKHosts.h index 348b7988f..5876f9e3b 100644 --- a/BlinkConfig/BKHosts.h +++ b/BlinkConfig/BKHosts.h @@ -37,8 +37,7 @@ enum BKMoshPrediction { BKMoshPredictionAdaptive, BKMoshPredictionAlways, BKMoshPredictionNever, - BKMoshPredictionExperimental, - BKMoshPredictionUnknown + BKMoshPredictionExperimental }; enum BKMoshExperimentalIP { @@ -106,6 +105,7 @@ enum BKAgentForward { agentForwardPrompt:(enum BKAgentForward)agentForwardPrompt agentForwardKeys:(NSArray *)agentForwardKeys ; ++ (void)_replaceHost:(BKHosts *)newHost; + (void)updateHost:(NSString *)host withiCloudId:(CKRecordID *)iCloudId andLastModifiedTime:(NSDate *)lastModifiedTime; + (void)markHost:(NSString *)host forRecord:(CKRecord *)record withConflict:(BOOL)hasConflict; + (NSMutableArray *)all; diff --git a/BlinkConfig/BKHosts.m b/BlinkConfig/BKHosts.m index b4a91d342..e3c487464 100644 --- a/BlinkConfig/BKHosts.m +++ b/BlinkConfig/BKHosts.m @@ -30,7 +30,6 @@ //////////////////////////////////////////////////////////////////////////////// #import "BKHosts.h" -#import "BKMiniLog.h" #import "BKiCloudSyncHandler.h" #import "UICKeyChainStore.h" #import "BlinkPaths.h" @@ -301,6 +300,19 @@ + (instancetype)saveHost:(NSString *)host return bkHost; } +// Helper to replace a Host, but won't process changes like passwords, etc... ++ (void)_replaceHost:(BKHosts *)newHost +{ + for (int i = 0; i < __hosts.count; i++) { + BKHosts *host = __hosts[i]; + if ([host->_host isEqualToString:newHost->_host]) { + __hosts[i] = newHost; + [self updateHost:host.host withiCloudId: host.iCloudRecordId andLastModifiedTime:[NSDate now]]; + return; + } + } +} + + (void)updateHost:(NSString *)host withiCloudId:(CKRecordID *)iCloudId andLastModifiedTime:(NSDate *)lastModifiedTime { BKHosts *bkHost = [BKHosts withHost:host]; diff --git a/BlinkConfig/BKHosts.swift b/BlinkConfig/BKHosts.swift index e1752a72b..dbd09d663 100644 --- a/BlinkConfig/BKHosts.swift +++ b/BlinkConfig/BKHosts.swift @@ -59,6 +59,10 @@ extension BKHosts { if let proxyJump = h.proxyJump, !proxyJump.isEmpty { cfg.append(("ProxyJump", proxyJump)) } + if let agentForwardPrompt = h.agentForwardPrompt, + agentForwardPrompt.intValue > 0 { + cfg.append(("ForwardAgent", "yes")) + } if let sshConfigAttachment = h.sshConfigAttachment, !sshConfigAttachment.isEmpty { sshConfigAttachment.split(whereSeparator: \.isNewline).forEach { line in let components = line diff --git a/BlinkConfig/BKMiniLog.m b/BlinkConfig/BKMiniLog.m deleted file mode 100644 index 0738a6e77..000000000 --- a/BlinkConfig/BKMiniLog.m +++ /dev/null @@ -1,84 +0,0 @@ -////////////////////////////////////////////////////////////////////////////////// -// -// B L I N K -// -// Copyright (C) 2016-2019 Blink Mobile Shell Project -// -// This file is part of Blink. -// -// Blink is free software: you can redistribute it and/or modify -// it under the terms of the GNU General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// Blink is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with Blink. If not, see . -// -// In addition, Blink is also subject to certain additional terms under -// GNU GPL version 3 section 7. -// -// You should have received a copy of these additional terms immediately -// following the terms and conditions of the GNU General Public License -// which accompanied the Blink Source Code. If not, see -// . -// -//////////////////////////////////////////////////////////////////////////////// - - -#import "BKMiniLog.h" -#import "BlinkPaths.h" -#import -#import - -@implementation BKMiniLog { - NSString *_name; - NSMutableArray *_records; -} - -- (instancetype)initWithName:(NSString *)name { - self = [super init]; - if (self) { - _name = name; - _records = [[NSMutableArray alloc] init]; - } - return self; -} - -- (void)log:(NSString *)record { - NSLog(@"%@:%@", _name, record); - [_records addObject:record]; -} - -- (void)save { - - [_records addObject:@""]; - NSString *log = [_records componentsJoinedByString:@"\n"]; - NSString *name = _name; - NSString *logPath = [[BlinkPaths blink] stringByAppendingPathComponent:name]; - - dispatch_after(DISPATCH_TIME_NOW + 1.0 * NSEC_PER_SEC, dispatch_get_main_queue(), ^{ - NSError *err = nil; - if (![log writeToFile: logPath atomically:YES encoding:NSUTF8StringEncoding error: &err]) { - NSLog(@"%@: Error writing log %@", name, err); - - OwnAlertController *alert = [OwnAlertController - alertControllerWithTitle:@"iOS15 Error Trace. Please report." - message:[NSString stringWithFormat: @"There was an issue storing trace logs. %@", err] - preferredStyle:UIAlertControllerStyleAlert]; - - UIAlertAction *ok = [UIAlertAction actionWithTitle:@"OK" style:UIAlertActionStyleDefault handler:nil]; - [alert addAction:ok]; - dispatch_after(dispatch_time(DISPATCH_TIME_NOW, 1 * NSEC_PER_SEC), dispatch_get_main_queue(), ^(void) { - [alert presentWithAnimated:true completion:nil]; - }); - } - }); - -} - -@end diff --git a/BlinkConfig/BKPubKey.m b/BlinkConfig/BKPubKey.m index c812032fe..137d78bce 100644 --- a/BlinkConfig/BKPubKey.m +++ b/BlinkConfig/BKPubKey.m @@ -33,7 +33,6 @@ #import #import "BKPubKey.h" -#import "BKMiniLog.h" #import "UICKeyChainStore.h" #import diff --git a/BlinkConfig/BKSSHHost.swift b/BlinkConfig/BKSSHHost.swift index c196a8d5c..d596b3077 100644 --- a/BlinkConfig/BKSSHHost.swift +++ b/BlinkConfig/BKSSHHost.swift @@ -48,6 +48,7 @@ public struct BKSSHHost { public var dynamicForward: [OptionalBindAddressInfo]? public var exitOnForwardFailure: Bool? public var forwardAgent: Bool? + public var gatewayPorts: Bool? //public var hostKeyAlias: String? public var hostbasedAuthentication: Bool? public var hostKeyAlgorithms: String? @@ -113,6 +114,7 @@ public struct BKSSHHost { case "dynamicforward": self.dynamicForward = try castList(value) case "exitonforwardfailure": self.exitOnForwardFailure = try castValue(value) case "forwardagent": self.forwardAgent = try castValue(value) + case "gatewayports": self.gatewayPorts = try castValue(value) case "kbdinteractiveauthentication": self.kbdInteractiveAuthentication = try castValue(value) case "hostbasedauthentication": self.hostbasedAuthentication = try castValue(value) case "hostkeyalgorithms": self.hostKeyAlgorithms = try castValue(value) @@ -182,7 +184,8 @@ public struct BKSSHHost { kbdInteractiveAuthentication: kbdInteractiveAuthentication, passwordAuthentication: passwordAuthentication, pubKeyAuthentication: pubKeyAuthentication, - hostbasedAuthentication: hostbasedAuthentication + hostbasedAuthentication: hostbasedAuthentication, + gatewayPorts: gatewayPorts ) } } diff --git a/BlinkConfig/BlinkPaths.h b/BlinkConfig/BlinkPaths.h index 1a8240e7f..73b0c5536 100644 --- a/BlinkConfig/BlinkPaths.h +++ b/BlinkConfig/BlinkPaths.h @@ -34,6 +34,7 @@ @interface BlinkPaths : NSObject + (NSString *) homePath; ++ (NSURL *)homeURL; + (NSString *) groupContainerPath; + (NSString *) documentsPath; @@ -43,10 +44,14 @@ + (NSString *) blink; // ~/.blink-build + (NSString *)blinkBuild; +// ~/.blink/agents ++ (NSString *)blinkAgentSettings; + // ~/.ssh + (NSString *) ssh; + (NSURL *) blinkURL; ++ (NSURL *) blinkAgentSettingsURL; + (NSURL *) blinkBuildURL; + (NSURL *) blinkBuildTokenURL; + (NSURL *)blinkBuildStagingMarkURL; @@ -68,13 +73,13 @@ + (NSURL *) localSnippetsLocationURL; + (NSURL *) iCloudSnippetsLocationURL; ++ (NSURL *)fileProviderReplicatedURL; ++ (NSURL *)fileProviderRemotesURLWithRecreate:(BOOL)recreate; + + (NSURL *)fileProviderErrorLogURL; + (NSURL *)blinkCodeErrorLogURL; + (void)linkICloudDriveIfNeeded; + (void)linkDocumentsIfNeeded; -+ (NSArray *)cleanedSymlinksInHomeDirectory; - - @end diff --git a/BlinkConfig/BlinkPaths.m b/BlinkConfig/BlinkPaths.m index ed81bb8d0..43bb159b9 100644 --- a/BlinkConfig/BlinkPaths.m +++ b/BlinkConfig/BlinkPaths.m @@ -43,10 +43,15 @@ + (NSString *)homePath { if (__homePath == nil) { __homePath = [[self groupContainerPath] stringByAppendingPathComponent:@"home"]; } - + return __homePath; } ++ (NSURL *)homeURL +{ + return [NSURL fileURLWithPath:[self homePath]]; +} + + (NSString *)documentsPath { if (__documentsPath == nil) { @@ -63,7 +68,7 @@ + (NSString *)groupContainerPath { if (__groupContainerPath == nil) { NSString *groupID = [XCConfig infoPlistFullGroupID]; - + NSFileManager *fm = [NSFileManager defaultManager]; NSString *path = [fm containerURLForSecurityApplicationGroupIdentifier:groupID].path; __groupContainerPath = path; @@ -80,7 +85,7 @@ + (NSString *)iCloudDriveDocuments [self _ensureFolderAtPath:path]; __iCloudsDriveDocumentsPath = path; } - + return __iCloudsDriveDocumentsPath; } @@ -95,15 +100,26 @@ + (void)linkDocumentsIfNeeded { destinationPath:[self documentsPath]]; } -+ (void)_linkAtPath:(NSString *)atPath destinationPath:(NSString *)destinationPath { ++ (void)_linkAtPath:(NSString *)path destinationPath:(NSString *)destinationPath { NSFileManager *fm = [NSFileManager defaultManager]; - if ([fm fileExistsAtPath:atPath]) { - return; - } + // Don't use fileExists as that would traverse the symlink. + if ([fm attributesOfItemAtPath:path error:nil]) { + NSString *currentDestinationPath = [fm destinationOfSymbolicLinkAtPath:path error:nil]; + if (!currentDestinationPath) { + return; + } + + // We lost access. Remove that symlink. + if (![fm isReadableFileAtPath: currentDestinationPath]) { + [fm removeItemAtPath: path error: nil]; + } else { + return; + } + } NSError *error = nil; - - BOOL ok = [fm createSymbolicLinkAtPath:atPath + + BOOL ok = [fm createSymbolicLinkAtPath:path withDestinationPath:destinationPath error:&error]; @@ -131,6 +147,12 @@ + (NSString *)ssh { return dotSSH; } ++ (NSString *)blinkAgentSettings { + NSString *path = [[self blink] stringByAppendingPathComponent:@"agents"]; + [self _ensureFolderAtPath:path]; + return path; +} + + (void)_ensureFolderAtPath:(NSString *)path { BOOL isDir = NO; NSFileManager *fm = [NSFileManager defaultManager]; @@ -138,7 +160,7 @@ + (void)_ensureFolderAtPath:(NSString *)path { if (isDir) { return; } - + [fm removeItemAtPath:path error:nil]; } [fm createDirectoryAtPath:path withIntermediateDirectories:YES attributes:@{} error:nil]; @@ -167,7 +189,10 @@ + (NSURL *)blinkBuildStagingMarkURL return [NSURL fileURLWithPath:[url stringByAppendingPathComponent:@".staging"]]; } - ++ (NSURL *)blinkAgentSettingsURL +{ + return [NSURL fileURLWithPath:[self blinkAgentSettings]]; +} + (NSURL *)sshURL { @@ -226,7 +251,17 @@ + (NSURL *)localSnippetsLocationURL } + (NSURL *)iCloudSnippetsLocationURL { - return [NSURL fileURLWithPath:[[self iCloudDriveDocuments] stringByAppendingPathComponent:@"snips"]]; + NSString *path = [self iCloudDriveDocuments]; + if (path) { + return [NSURL fileURLWithPath:[path stringByAppendingPathComponent:@"snips"]]; + } + return nil; +} + ++ (NSURL *)fileProviderReplicatedURL { + NSString *fileProviderPath = [[self groupContainerPath] stringByAppendingPathComponent:@"FileProviderReplicated"]; + [self _ensureFolderAtPath:fileProviderPath]; + return [NSURL fileURLWithPath:fileProviderPath]; } + (NSString *)knownHostsFile @@ -249,36 +284,4 @@ + (NSURL *)blinkCodeErrorLogURL return [[self blinkURL] URLByAppendingPathComponent:@"blinkCode.log"]; } -+ (NSArray *)cleanedSymlinksInHomeDirectory -{ - NSFileManager *fm = [NSFileManager defaultManager]; - NSMutableArray *allowedPaths = [[NSMutableArray alloc] init]; - - NSString *homePath = [BlinkPaths homePath]; - NSArray * files = [fm contentsOfDirectoryAtPath:homePath error:nil]; - - for (NSString *path in files) { - NSString *filePath = [homePath stringByAppendingPathComponent:path]; - NSDictionary * attrs = [fm attributesOfItemAtPath:filePath error:nil]; - if (attrs[NSFileType] != NSFileTypeSymbolicLink) { - continue; - } - - NSString *destPath = [fm destinationOfSymbolicLinkAtPath:filePath error:nil]; - if (!destPath) { - continue; - } - - if (![fm isReadableFileAtPath:destPath]) { - - // We lost access. Remove that symlink - [fm removeItemAtPath:filePath error:nil]; - continue; - } - - [allowedPaths addObject:destPath]; - } - return allowedPaths; -} - @end diff --git a/BlinkConfig/WebAuthnKey.swift b/BlinkConfig/WebAuthnKey.swift index a2a6af5af..cfbe8948f 100644 --- a/BlinkConfig/WebAuthnKey.swift +++ b/BlinkConfig/WebAuthnKey.swift @@ -39,6 +39,7 @@ import SwiftCBOR public protocol InputPrompter { func setPromptOnView(_ view: UIView) + func setLogger(_ logger: SSHLogPublisher, verbosity: SSHLogLevel) } public class WebAuthnKey: NSObject { @@ -46,8 +47,10 @@ public class WebAuthnKey: NSObject { let rawAttestationObject: Data var termView: UIView? = nil + var log: SSHLogger? = nil //var authAnchor: ASPresentationAnchor? = nil var signaturePub: PassthroughSubject! + var cancelSignature: AnyCancellable! public var comment: String? = nil @@ -74,6 +77,10 @@ extension WebAuthnKey: InputPrompter { public func setPromptOnView(_ view: UIView) { self.termView = view } + + public func setLogger(_ logger: SSHLogPublisher, verbosity: SSHLogLevel) { + self.log = SSHLogger(verbosity: verbosity, logger: logger) + } } @@ -86,27 +93,33 @@ extension WebAuthnKey: Signer { // an async interface to the Signers and Constraints. public func sign(_ message: Data, algorithm: String?) throws -> Data { guard self.termView != nil else { - throw WebAuthnError.clientError("Prompt not configured for request") + throw WebAuthnError.clientError("Prompt not configured for request.") } + self.log?.message("WebAuthn signature requested.", .info) + let authController = ASAuthorizationController(authorizationRequests: [self.signAuthorizationRequest(message)]) authController.delegate = self authController.presentationContextProvider = self - if #available(iOS 16.0, *) { let semaphore = DispatchSemaphore(value: 0) var signature: Data? = nil var error: Error? = nil self.signaturePub = PassthroughSubject() - // TODO Send it on main for now - let cancel = Just(authController) + // Controller needs to be displayed on main. + self.cancelSignature = Just(authController) .receive(on: DispatchQueue.main) .flatMap { authController in - authController.performRequests(options: .preferImmediatelyAvailableCredentials) + authController.performRequests() // options: .preferImmediatelyAvailableCredentials may suppress the UI, and it doesn't make sense in our scenario + self.log?.message("WebAuthn Controller called to perform request.", .debug) return self.signaturePub! } + .handleEvents(receiveCancel: { + self.log?.message("WebAuthn signature publisher cancelled", .debug) + }) .sink(receiveCompletion: { completion in + self.log?.message("WebAuthn received signature publisher completed with \(completion)", .debug) switch completion { case .failure(let err): error = err @@ -114,14 +127,19 @@ extension WebAuthnKey: Signer { break } semaphore.signal() - }, receiveValue: { signature = $0 }) - + }, receiveValue: { sig in + self.log?.message("WebAuthn signature received.", .debug) + signature = sig + }) + + self.log?.message("WebAuthn Controller awaiting response.", .debug) semaphore.wait() guard let signature = signature else { throw error! } + self.log?.message("WebAuthn signature completed.", .info) return signature } else { // Fallback on earlier versions @@ -135,12 +153,15 @@ extension WebAuthnKey: ASAuthorizationControllerDelegate, ASAuthorizationControl controller: ASAuthorizationController, didCompleteWithAuthorization authorization: ASAuthorization ) { - + self.log?.message("WebAuthn Controller received response.", .debug) + guard let credentialAssertion = authorization.credential as? ASAuthorizationPublicKeyCredentialAssertion, let rawSignature = credentialAssertion.signature else { - return signaturePub.send(completion: .failure(WebAuthnError.signatureError("Unexpected operation"))) + self.log?.message("WebAuthn Controller unexpected operation received.", .warn) + signaturePub.send(completion: .failure(WebAuthnError.signatureError("Unexpected operation"))) + return } let rawClientData = credentialAssertion.rawClientDataJSON @@ -153,12 +174,14 @@ extension WebAuthnKey: ASAuthorizationControllerDelegate, ASAuthorizationControl // TODO We should validate the CredentialID, to be sure we signed with the proper key, // before we fail or ask the user to retry. + self.log?.message("WebAuthn Controller sending signature", .debug) signaturePub.send(webAuthnSig) signaturePub.send(completion: .finished) } public func authorizationController(controller: ASAuthorizationController, didCompleteWithError error: Error) { + self.log?.message("WebAuthn signature failed or cancelled - \(error)", .warn) signaturePub.send(completion: .failure(error)) } diff --git a/Settings/ViewControllers/Subscriptions/EarlyFeaturesAccessLetterView.swift b/BlinkFileProvider/BlinkFileProvider.h similarity index 63% rename from Settings/ViewControllers/Subscriptions/EarlyFeaturesAccessLetterView.swift rename to BlinkFileProvider/BlinkFileProvider.h index a0a3c06b1..0ef5175ed 100644 --- a/Settings/ViewControllers/Subscriptions/EarlyFeaturesAccessLetterView.swift +++ b/BlinkFileProvider/BlinkFileProvider.h @@ -2,7 +2,7 @@ // // B L I N K // -// Copyright (C) 2016-2019 Blink Mobile Shell Project +// Copyright (C) 2016-2024 Blink Mobile Shell Project // // This file is part of Blink. // @@ -30,33 +30,14 @@ //////////////////////////////////////////////////////////////////////////////// -import Foundation -import SwiftUI +#import -enum EarlyFeatureAccessSteps { - case letter - case plans -} +//! Project version number for BlinkFileProvider. +FOUNDATION_EXPORT double BlinkFileProviderVersionNumber; + +//! Project version string for BlinkFileProvider. +FOUNDATION_EXPORT const unsigned char BlinkFileProviderVersionString[]; + +// In this header, you should import all the public headers of your framework using statements like #import -struct EarlyFeaturesAccessLetterView: View { - - @StateObject var rowsProvider: ViewModel - - var body: some View { - VStack { - GridView(rowsProvider: rowsProvider) - if rowsProvider.hasFetchedData { - Button("Upgrade to Blink Plus", action: { - EntitlementsManager.shared.navigationSteps.append(.plans) - }) - .buttonStyle(.borderedProminent) - .buttonBorderShape(.capsule) - .padding() - Spacer() - } - } - .navigationBarTitleDisplayMode(.inline) - } - -} diff --git a/BlinkFileProvider/FileProviderEnumerator.swift b/BlinkFileProvider/FileProviderEnumerator.swift deleted file mode 100644 index 25aaa7356..000000000 --- a/BlinkFileProvider/FileProviderEnumerator.swift +++ /dev/null @@ -1,231 +0,0 @@ -////////////////////////////////////////////////////////////////////////////////// -// -// B L I N K -// -// Copyright (C) 2016-2019 Blink Mobile Shell Project -// -// This file is part of Blink. -// -// Blink is free software: you can redistribute it and/or modify -// it under the terms of the GNU General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// Blink is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with Blink. If not, see . -// -// In addition, Blink is also subject to certain additional terms under -// GNU GPL version 3 section 7. -// -// You should have received a copy of these additional terms immediately -// following the terms and conditions of the GNU General Public License -// which accompanied the Blink Source Code. If not, see -// . -// -//////////////////////////////////////////////////////////////////////////////// - -import BlinkFiles -import FileProvider -import Combine - -import SSH - -class FileProviderEnumerator: NSObject, NSFileProviderEnumerator { - let identifier: BlinkItemIdentifier - let translator: AnyPublisher - let cache: FileTranslatorCache - var cancellableBag: Set = [] - var currentAnchor: Int = 0 - let log: BlinkLogger - - init(enumeratedItemIdentifier: NSFileProviderItemIdentifier, - domain: NSFileProviderDomain, - cache: FileTranslatorCache) { - // TODO An enumerator may be requested for an open file, in order to enumerate changes to it. - if enumeratedItemIdentifier == .rootContainer { - self.identifier = BlinkItemIdentifier(domain.pathRelativeToDocumentStorage) - } else { - self.identifier = BlinkItemIdentifier(enumeratedItemIdentifier) - } - - let path = self.identifier.path - self.cache = cache - - self.log = BlinkLogger("enumeratorFor \(path)") - self.log.debug("Initialized") - - self.translator = cache.rootTranslator(for: self.identifier) - .flatMap { t -> AnyPublisher in - path.isEmpty ? .just(t.clone()) : t.cloneWalkTo(path) - }.eraseToAnyPublisher() - - // TODO Schedule an interval enumeration (pull) from the server. - super.init() - } - - func invalidate() { - // TODO: perform invalidation of server connection if necessary? - // Stop the enumeration - self.log.debug("Invalidate") - cancellableBag = [] - } - - func enumerateItems(for observer: NSFileProviderEnumerationObserver, startingAt page: NSFileProviderPage) { - /* - - inspect the page to determine whether this is an initial or a follow-up request - - If this is an enumerator for a directory, the root container or all directories: - - perform a server request to fetch directory contents - If this is an enumerator for the active set: - - perform a server request to update your local database - - fetch the active set from your local database - - - inform the observer about the items returned by the server (possibly multiple times) - - inform the observer that you are finished with this page - */ - self.log.info("Enumeration requested") - - // We use the local files and the representation of the remotes to construct the view of the system. - // It is a simpler way to warm up the local cache without having a persistent representation. - var containerTranslator: Translator! - translator - .flatMap { t -> AnyPublisher in - containerTranslator = t - return t.stat() - } - .map { containerAttrs -> Translator in - // 1. Store the container reference - // TODO We may be able to skip this if stat would return '.' - if let reference = self.cache.reference(identifier: self.identifier) { - reference.updateAttributes(remote: containerAttrs) - } else { - let ref = BlinkItemReference(self.identifier, - remote: containerAttrs, - cache: self.cache) - self.cache.store(reference: ref) - } - return containerTranslator - } - .flatMap { - // 2. Stat both local and remote files. - // For remote, if the file is a link, then stat to know the real attributes - Publishers.Zip($0.isDirectory ? $0.directoryFilesAndAttributesResolvingLinks() : AnyPublisher($0.stat().collect()), - Local().walkTo(self.identifier.url.path) - .flatMap { $0.isDirectory ? $0.directoryFilesAndAttributes() : AnyPublisher($0.stat().collect()) } - .catch { _ in AnyPublisher.just([]) }) - } - .map { (remoteFilesAttributes, localFilesAttributes) -> [BlinkItemReference] in - // 3.1 Collect all current file references - return remoteFilesAttributes.map { attrs -> BlinkItemReference in - // 3.2 Match local and remote files, and upsert accordingly - let fileIdentifier = BlinkItemIdentifier(parentItemIdentifier: self.identifier, - filename: attrs[.name] as! String) - // Find a local file that matches the remote. - let localAttrs = localFilesAttributes.first(where: { $0[.name] as! String == fileIdentifier.filename }) - - if let reference = self.cache.reference(identifier: fileIdentifier) { - reference.updateAttributes(remote: attrs, local: localAttrs) - return reference - } else { - let ref = BlinkItemReference(fileIdentifier, - remote: attrs, - local: localAttrs, - cache: self.cache) - - // Store the reference in the internal DB for later usage. - self.cache.store(reference: ref) - return ref - } - } - } - .sink( - receiveCompletion: { completion in - switch completion { - case .failure(let error): - self.log.error("\(error)") - observer.finishEnumeratingWithError(error) - case .finished: - observer.finishEnumerating(upTo: nil) - } - }, - receiveValue: { - self.log.info("Enumerated \($0.count) items") - observer.didEnumerate($0) - }).store(in: &cancellableBag) - } - - func enumerateChanges(for observer: NSFileProviderChangeObserver, from anchor: NSFileProviderSyncAnchor) { - /* TODO: - - query the server for updates since the passed-in sync anchor - - If this is an enumerator for the active set: - - note the changes in your local database - - - inform the observer about item deletions and updates (modifications + insertions) - - inform the observer when you have finished enumerating up to a subsequent sync anchor - */ - // Schedule changes - - let anchor = UInt(String(data: anchor.rawValue, encoding: .utf8)!)! - self.log.info("Enumerating changes at \(anchor) anchor") - - guard let ref = self.cache.reference(identifier: self.identifier) else { - observer.finishEnumeratingWithError("Op not supported") - return - } - - if let updatedItems = self.cache.updatedItems(container: self.identifier, since: anchor) { - // Atm only update changes, no deletion as we don't provide tombstone values. - self.log.info("\(updatedItems.count) items updated.") - observer.didUpdate(updatedItems) - } else if anchor < ref.syncAnchor { - observer.didUpdate([ref]) - } - - let newAnchor = ref.syncAnchor - let data = "\(newAnchor)".data(using: .utf8) - - observer.finishEnumeratingChanges(upTo: NSFileProviderSyncAnchor(data!), moreComing: false) - } - - - /** - Request the current sync anchor. - - To keep an enumeration updated, the system will typically - - request the current sync anchor (1) - - enumerate items starting with an initial page - - continue enumerating pages, each time from the page returned in the previous - enumeration, until finishEnumeratingUpToPage: is called with nextPage set to - nil - - enumerate changes starting from the sync anchor returned in (1) - - continue enumerating changes, each time from the sync anchor returned in the - previous enumeration, until finishEnumeratingChangesUpToSyncAnchor: is called - with moreComing:NO - - This method will be called again if you signal that there are more changes with - -[NSFileProviderManager signalEnumeratorForContainerItemIdentifier: - completionHandler:] and again, the system will enumerate changes until - finishEnumeratingChangesUpToSyncAnchor: is called with moreComing:NO. - - NOTE that the change-based observation methods are marked optional for historical - reasons, but are really required. System performance will be severely degraded if - they are not implemented. - */ - func currentSyncAnchor(completionHandler: @escaping (NSFileProviderSyncAnchor?) -> Void) { - - guard let ref = self.cache.reference(identifier: self.identifier) else { - completionHandler(nil) - return - } - self.log.info("Requested anchor \(ref.syncAnchor)") - - let data = "\(ref.syncAnchor)".data(using: .utf8) - completionHandler(NSFileProviderSyncAnchor(data!)) - } -} diff --git a/BlinkFileProvider/FileProviderExtension.swift b/BlinkFileProvider/FileProviderExtension.swift deleted file mode 100644 index 6350e6b80..000000000 --- a/BlinkFileProvider/FileProviderExtension.swift +++ /dev/null @@ -1,642 +0,0 @@ -////////////////////////////////////////////////////////////////////////////////// -// -// B L I N K -// -// Copyright (C) 2016-2019 Blink Mobile Shell Project -// -// This file is part of Blink. -// -// Blink is free software: you can redistribute it and/or modify -// it under the terms of the GNU General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// Blink is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with Blink. If not, see . -// -// In addition, Blink is also subject to certain additional terms under -// GNU GPL version 3 section 7. -// -// You should have received a copy of these additional terms immediately -// following the terms and conditions of the GNU General Public License -// which accompanied the Blink Source Code. If not, see -// . -// -//////////////////////////////////////////////////////////////////////////////// - - -import Combine -import FileProvider - -import BlinkFiles -import BlinkConfig - -// TODO Provide proper error subclassing. BlinkFilesProviderError -extension String: Error {} - - -class FileProviderExtension: NSFileProviderExtension { - - var fileManager = FileManager() - var cache = FileTranslatorCache() - var cancellableBag: Set = [] - let copyArguments = CopyArguments(inplace: true, - preserve: [.permissions, .timestamp], - checkTimes: true) - override init() { - super.init() - - let dateFormatter = DateFormatter() - dateFormatter.dateFormat = "MMM dd YYYY, HH:mm:ss" - - BlinkLogging.handle( - { - $0.filter(logLevel: .debug) - .format { [ $0[.component] as? String ?? "global", - $0[.message] as? String ?? "" - ].joined(separator: " ") } - .sinkToOutput() - } - ) - - guard let file = try? FileLogging(to: BlinkPaths.fileProviderErrorLogURL()) else { - print("File logging not configured") - return - } - // Configure logging so all goes to file (filtered by error level) and output. - BlinkLogging.handle( - { - try $0.filter(logLevel: .debug) - // Format - .format { [ dateFormatter.string(from: Date()), - $0[.component] as? String ?? "global", - "[\($0[.logLevel]!)]", - $0[.message] as? String ?? "" - ].joined(separator: " ") } - .sinkToFile(file) - } - ) - } - - // MARK: - BlinkItem Entry : DB-GET query (using uniq NSFileProviderItemIdentifier ID) - override func item(for identifier: NSFileProviderItemIdentifier) throws -> NSFileProviderItem { - let log = BlinkLogger("itemFor") - log.info("\(identifier)") - - var queryableIdentifier: BlinkItemIdentifier! - - if identifier == .rootContainer { - guard let encodedRootPath = domain?.pathRelativeToDocumentStorage else { - let error = NSFileProviderError(.noSuchItem) - log.error("\(error)") - throw error - } - queryableIdentifier = BlinkItemIdentifier(encodedRootPath) - } else { - queryableIdentifier = BlinkItemIdentifier(identifier) - } - - guard let reference = self.cache.reference(identifier: queryableIdentifier) else { - if identifier == .rootContainer { - let attributes = try? fileManager.attributesOfItem(atPath: queryableIdentifier.url.path) - return BlinkItemReference(queryableIdentifier, local: attributes, cache: self.cache) - } - log.error("No reference found for ITEM \(queryableIdentifier.path)") - throw NSError.fileProviderErrorForNonExistentItem(withIdentifier: identifier) - } - - return reference - } - - override func urlForItem(withPersistentIdentifier identifier: NSFileProviderItemIdentifier) -> URL? { - let blinkItemFromId = BlinkItemIdentifier(identifier) - BlinkLogger("urlForItem").debug("\(blinkItemFromId.itemIdentifier)") - return blinkItemFromId.url - } - - // MARK: - Actions - - override func persistentIdentifierForItem(at url: URL) -> NSFileProviderItemIdentifier? { - BlinkLogger("persistentIdentifierForItem").debug("\(url.path)") - guard let ref = self.cache.reference(url: url) else { - return nil - } - return ref.itemIdentifier - } - - override func providePlaceholder(at url: URL, completionHandler: @escaping (Error?) -> Void) { - let log = BlinkLogger("providePlaceHolder") - log.info("\(url.path)") - - //A.1. Get the document’s persistent identifier by calling persistentIdentifierForItemAtURL:, and pass in the value of the url parameter. - let localDirectory = url.deletingLastPathComponent() - - do { - try fileManager.createDirectory( - at: localDirectory, - withIntermediateDirectories: true, - attributes: nil - ) - } catch { - log.error("\(error)") - completionHandler(error) - return - } - - //A Look Up the Document's File Provider Item - guard let identifier = persistentIdentifierForItem(at: url) else { - completionHandler(NSFileProviderError(.noSuchItem)) - return - } - log.debug("identifier \(identifier)") - - do { - - //A.2. Call itemForIdentifier:error:, and pass in the persistent identifier. This method returns the file provider item for the document. - let fileProviderItem = try item(for: identifier) - - // B. Write the Placeholder - // B.1 Get the placeholder URL by calling placeholderURLForURL:, and pass in the value of the url parameter. - let placeholderURL = NSFileProviderManager.placeholderURL(for: url) - - // B.2 Call writePlaceholderAtURL:withMetadata:error:, and pass in the placeholder URL and the file provider item. - try NSFileProviderManager.writePlaceholder(at: placeholderURL,withMetadata: fileProviderItem) - - completionHandler(nil) - - } catch let error { - log.error("\(error)") - completionHandler(error) - return - } - } - - override func startProvidingItem(at url: URL, completionHandler: @escaping ((_ error: Error?) -> Void)) { - // 1 - From URL we get the identifier. - let log = BlinkLogger("startProvidingItem") - log.info("\(url)") - - //let blinkIdentifier = BlinkItemIdentifier(url: url) - guard let blinkItemReference = self.cache.reference(url: url) else { - //guard let blinkItemReference = FileTranslatorCache.reference(identifier: blinkIdentifier) else { - // TODO Proper error types (NSError) - log.error("No reference found") - completionHandler(NSFileProviderError(.noSuchItem)) - return - } - - guard !blinkItemReference.isDownloaded else { - log.info("\(blinkItemReference.path) - current item up to date") - completionHandler(nil) - return - } - - // 2 local translator - let destTranslator = Local().cloneWalkTo(url.deletingLastPathComponent().path) - - // 3 remote - From the identifier, we get the translator and walk to the remote. - let srcTranslator = self.cache.rootTranslator(for: BlinkItemIdentifier(blinkItemReference.itemIdentifier)) - let downloadTask = srcTranslator.flatMap { - $0.cloneWalkTo(blinkItemReference.path) - } - .flatMap { fileTranslator in - // 4 - Start the copy - return destTranslator.flatMap { $0.copy(from: [fileTranslator], - args: self.copyArguments) } - } - .sink(receiveCompletion: { completion in - switch completion { - case .finished: - log.info("\(blinkItemReference.path) - completed") - blinkItemReference.downloadCompleted(nil) - completionHandler(nil) - self.signalEnumerator(for: blinkItemReference.parentItemIdentifier) - case .failure(let error): - log.error("\(error)") - completionHandler(NSFileProviderError.operationError(dueTo: error)) - self.signalEnumerator(for: blinkItemReference.parentItemIdentifier) - } - }, receiveValue: { _ in }) - - blinkItemReference.downloadStarted(downloadTask) - self.signalEnumerator(for: blinkItemReference.parentItemIdentifier) - } - - override func stopProvidingItem(at url: URL) { - let log = BlinkLogger("stopProvidingItem") - log.info("\(url.path)") - // Called after the last claim to the file has been released. At this point, it is safe for the file provider to remove the content file. - // Care should be taken that the corresponding placeholder file stays behind after the content file has been deleted. - - // Called after the last claim to the file has been released. At this point, it is safe for the file provider to remove the content file. - - // TODO: look up whether the file has local changes - let fileHasLocalChanges = false - - if !fileHasLocalChanges { - // remove the existing file to free up space - do { - _ = try FileManager.default.removeItem(at: url) - } catch { - // Handle error - log.error("\(error)") - } - - // write out a placeholder to facilitate future property lookups - self.providePlaceholder(at: url, completionHandler: { error in - // TODO The placeholder will take into account the file, but we will need to make sure - // that the Reference know that the local file is actually empty. - // This means if mtime is a reference, the size should be too, in order to differentiate - // files we already have from those that need to be downloaded. - // TODO: handle any error, do any necessary cleanup - }) - } - } - - override func importDocument(at fileURL: URL, toParentItemIdentifier parentItemIdentifier: NSFileProviderItemIdentifier, completionHandler: @escaping (NSFileProviderItem?, Error?) -> Void) { - let log = BlinkLogger("importDocument") - print("importDocument at \(fileURL.path)") - - let parentBlinkIdentifier: BlinkItemIdentifier! - if parentItemIdentifier == .rootContainer { - parentBlinkIdentifier = BlinkItemIdentifier(domain!.pathRelativeToDocumentStorage) - } else { - parentBlinkIdentifier = BlinkItemIdentifier(parentItemIdentifier) - } - - let fileBlinkIdentifier = BlinkItemIdentifier(parentItemIdentifier: parentBlinkIdentifier, filename: fileURL.lastPathComponent) - let localFileURLDirectory = fileBlinkIdentifier.url.deletingLastPathComponent().path - - var attributes: FileAttributes! - do { - attributes = try fileManager.attributesOfItem(atPath: fileURL.path) - attributes[.name] = fileBlinkIdentifier.url.lastPathComponent - } catch { - log.error("Could not fetch attributes of item - \(error)") - completionHandler(nil, NSFileProviderError.operationError(dueTo: error)) - return - } - - // Copy only Regular files, do not support directories yet. - if attributes[.type] as! FileAttributeType != .typeRegular { - log.error("Directories not supported for this operation") - completionHandler(nil, NSFileProviderError(.noSuchItem)) - return - } - - // Move file to the provider container. - do { - try moveFile(fileURL, to: localFileURLDirectory) - } catch { - log.error("File could not be moved to container - \(error)") - completionHandler(nil, error) - } - - let blinkItemReference = BlinkItemReference(fileBlinkIdentifier, local: attributes, cache: self.cache) - self.cache.store(reference: blinkItemReference) - - // 1. Translator for local target path - let localFileURLPath = fileBlinkIdentifier.url.path - let srcTranslator = Local().cloneWalkTo(localFileURLPath) - - // 2. translator for remote target path - let destTranslator = self.cache.rootTranslator(for: parentBlinkIdentifier) - .flatMap { $0.cloneWalkTo(parentBlinkIdentifier.path) } - - let c = destTranslator.flatMap { remotePathTranslator in - return srcTranslator.flatMap{ localFileTranslator -> CopyProgressInfoPublisher in - // 3. Start copy - return remotePathTranslator.copy(from: [localFileTranslator], - args: self.copyArguments) - } - }.sink { completion in - // 4. Update reference and notify - if case let .failure(error) = completion { - log.error("Upload failed \(localFileURLPath)- \(error)") - blinkItemReference.uploadCompleted(error) - completionHandler(blinkItemReference, - NSFileProviderError.operationError(dueTo: error)) - self.signalEnumerator(for: blinkItemReference.parentItemIdentifier) - return - } - - blinkItemReference.uploadCompleted(nil) - log.info("Upload completed \(localFileURLPath)") - completionHandler(blinkItemReference, nil) - self.signalEnumerator(for: blinkItemReference.parentItemIdentifier) - } receiveValue: { _ in } - - blinkItemReference.uploadStarted(c) - self.signalEnumerator(for: blinkItemReference.parentItemIdentifier) - } - - override func itemChanged(at url: URL) { - // Called at some point after the file has changed; the provider may then trigger an upload - let log = BlinkLogger("itemChanged") - log.info("\(url.path)") - - guard var blinkItemReference = self.cache.reference(url: url) else { - log.error("Could not find reference to item") - return - } - // - if there are existing NSURLSessionTasks uploading this file, cancel them - // Cancel an upload if there is a reference to it. - blinkItemReference.uploadingTask?.cancel() - - // - mark file at as needing an update in the model - // Update the model - var attributes: FileAttributes! - do { - attributes = try fileManager.attributesOfItem(atPath: url.path) - attributes[.name] = url.lastPathComponent - } catch { - log.error("Could not fetch attributes of item - \(error)") - return - } - // Replace the reference to the local - blinkItemReference = BlinkItemReference(BlinkItemIdentifier(blinkItemReference.itemIdentifier), - local: attributes, - cache: self.cache) - self.cache.store(reference: blinkItemReference) - - - // - create a fresh background NSURLSessionTask and schedule it to upload the current modifications - // 1. Translator for local target path - let localFileURLPath = url.path - let srcTranslator = Local().cloneWalkTo(localFileURLPath) - - // 2. Translator for remote file path - let itemIdentifier = blinkItemReference.itemIdentifier - let destTranslator = self.cache.rootTranslator(for: BlinkItemIdentifier(itemIdentifier)) - .flatMap { $0.cloneWalkTo(BlinkItemIdentifier(blinkItemReference.parentItemIdentifier).path) } - - // 3. Upload - let c = destTranslator.flatMap { remotePathTranslator in - return srcTranslator.flatMap{ localFileTranslator -> CopyProgressInfoPublisher in - // 3. Start copy - return remotePathTranslator.copy(from: [localFileTranslator], - args: self.copyArguments) - } - }.sink { completion in - // 4. Update reference and notify - if case let .failure(error) = completion { - log.error("Upload failed \(localFileURLPath)- \(error)") - blinkItemReference.uploadCompleted(error) - - self.signalEnumerator(for: blinkItemReference.parentItemIdentifier) - return - } - - blinkItemReference.uploadCompleted(nil) - self.signalEnumerator(for: blinkItemReference.parentItemIdentifier) - - log.info("Upload completed \(localFileURLPath)") - } receiveValue: { _ in } - - blinkItemReference.uploadStarted(c) - - self.signalEnumerator(for: blinkItemReference.parentItemIdentifier) - } - - override func createDirectory(withName directoryName: String, - inParentItemIdentifier parentItemIdentifier: NSFileProviderItemIdentifier, - completionHandler: @escaping (NSFileProviderItem?, Error?) -> Void) { - let log = BlinkLogger("createDirectory") - log.info("\(directoryName) at \(parentItemIdentifier.rawValue)") - - let parentBlinkIdentifier: BlinkItemIdentifier! - if parentItemIdentifier == .rootContainer { - parentBlinkIdentifier = BlinkItemIdentifier(domain!.pathRelativeToDocumentStorage) - } else { - parentBlinkIdentifier = BlinkItemIdentifier(parentItemIdentifier) - } - - let translator = self.cache.rootTranslator(for: parentBlinkIdentifier) - - var directoryBlinkIdentifier = BlinkItemIdentifier(parentItemIdentifier: parentBlinkIdentifier, filename: directoryName) - - translator - .flatMap { - $0.cloneWalkTo(parentBlinkIdentifier.path) - } - .flatMap { t -> AnyPublisher in - var tries = 1 - while (self.cache.reference(identifier: directoryBlinkIdentifier) != nil) { - tries += 1 - - directoryBlinkIdentifier = BlinkItemIdentifier(parentItemIdentifier: parentBlinkIdentifier, - filename: "\(directoryName) \(tries)") - } - - return t.mkdir(name: directoryBlinkIdentifier.filename, - mode: S_IRWXU | S_IRWXG | S_IRWXO) - .eraseToAnyPublisher() - } - .flatMap { - $0.stat() - } - .sink( - receiveCompletion: { completion in - if case let .failure(error) = completion { - log.error("\(error)") - completionHandler(nil, NSFileProviderError.operationError(dueTo: error)) - } - }, - receiveValue: { attrs in - let ref = BlinkItemReference(directoryBlinkIdentifier, remote: attrs, cache: self.cache) - self.cache.store(reference: ref) - completionHandler(ref, nil) - } - ).store(in: &cancellableBag) - } - - override func renameItem(withIdentifier itemIdentifier: NSFileProviderItemIdentifier, toName itemName: String, completionHandler: @escaping (NSFileProviderItem?, Error?) -> Void) { - let log = BlinkLogger("renameItem") - log.info("\(itemIdentifier) as \(itemName)") - - let blinkItemIdentifier = BlinkItemIdentifier(itemIdentifier) - guard let blinkItemReference = self.cache.reference(identifier: blinkItemIdentifier) else { - completionHandler(nil, NSFileProviderError(.noSuchItem)) - return - } - let parentItemIdentifier = BlinkItemIdentifier(blinkItemIdentifier.parentIdentifier) - - let newItemIdentifier = BlinkItemIdentifier(parentItemIdentifier: parentItemIdentifier, - filename: itemName) - - if let _ = self.cache.reference(identifier: newItemIdentifier) { - completionHandler(nil, NSFileProviderError(.filenameCollision)) - return - } - - self.cache.rootTranslator(for: blinkItemIdentifier) - .flatMap { t in - t.cloneWalkTo(blinkItemIdentifier.path) - .flatMap { $0.wstat([.name: itemName]) } - .map { _ in t } - } - .flatMap { $0.cloneWalkTo(newItemIdentifier.path) } - .flatMap { $0.stat() } - .sink( - receiveCompletion: { completion in - if case let .failure(error) = completion { - log.error("\(error)") - completionHandler(nil, NSFileProviderError.operationError(dueTo: error)) - } - }, - receiveValue: { attrs in - self.cache.remove(reference: blinkItemReference) - let newBlinkItemReference = BlinkItemReference(newItemIdentifier, remote: attrs, cache: self.cache) - self.cache.store(reference: newBlinkItemReference) - completionHandler(newBlinkItemReference, nil) - } - ).store(in: &cancellableBag) - } - - override func deleteItem(withIdentifier itemIdentifier: NSFileProviderItemIdentifier, completionHandler: @escaping (Error?) -> Void) { - let log = BlinkLogger("deleteItem") - log.info("\(itemIdentifier)") - - let blinkItemIdentifier = BlinkItemIdentifier(itemIdentifier) - guard let blinkItemReference = self.cache.reference(identifier: blinkItemIdentifier) else { - completionHandler(NSFileProviderError(.noSuchItem)) - return - } - - let recursive = true - - func delete(_ translators: [Translator]) -> AnyPublisher { - translators.publisher - .flatMap(maxPublishers: .max(1)) { t -> AnyPublisher in - print(t.current) - if t.fileType == .typeDirectory { - return [deleteDirectoryContent(t), AnyPublisher(t.rmdir().map {_ in})] - .compactMap { $0 } - .publisher - .flatMap(maxPublishers: .max(1)) { $0 } - .collect() - .map {_ in} - .eraseToAnyPublisher() - } - - return AnyPublisher(t.remove().map { _ in }) - }.eraseToAnyPublisher() - } - - func deleteDirectoryContent(_ t: Translator) -> AnyPublisher? { - if recursive == false { - return nil - } - - return t.directoryFilesAndAttributes().flatMap { - $0.compactMap { i -> FileAttributes? in - if (i[.name] as! String) == "." || (i[.name] as! String) == ".." { - return nil - } else { - return i - } - }.publisher - } - .flatMap { - t.cloneWalkTo($0[.name] as! String) } - .collect() - .flatMap { - delete($0) } - .eraseToAnyPublisher() - } - - self.cache.rootTranslator(for: blinkItemIdentifier) - .flatMap { - $0.cloneWalkTo(blinkItemIdentifier.path) - .flatMap { delete([$0]) } - } - .sink( - receiveCompletion: { completion in - if case let .failure(error) = completion { - log.error("\(error)") - completionHandler(NSFileProviderError.operationError(dueTo: error)) - } else { - // NOTE We may want to delete the other references as well. But as this is an in-memory cache, - // just deleting the parent reference should be enough. - self.cache.remove(reference: blinkItemReference) - _ = try? FileManager.default.removeItem(at: blinkItemReference.url) - completionHandler(nil) - } - }, - receiveValue: { _ in } - ).store(in: &cancellableBag) - } - - // MARK: - Enumeration - - override func enumerator(for containerItemIdentifier: NSFileProviderItemIdentifier) throws -> NSFileProviderEnumerator { - let log = BlinkLogger("enumerator") - log.info("\(containerItemIdentifier.rawValue)") - - guard let domain = self.domain else { - log.error("No domain provided") - throw NSFileProviderError.noDomainProvided - } - - if (containerItemIdentifier != NSFileProviderItemIdentifier.workingSet) { - return FileProviderEnumerator(enumeratedItemIdentifier: containerItemIdentifier, domain: domain, cache: self.cache) - } else { - // We may want to do an empty FileProviderEnumerator, because otherwise it will try to request it again and again. - throw NSError(domain: NSCocoaErrorDomain, code: NSFeatureUnsupportedError, userInfo:[:]) - } - } - - // MARK: - Private - private func moveFile(_ fileURL: URL, to targetPath: String) throws { - _ = fileURL.startAccessingSecurityScopedResource() - - var isDirectory: ObjCBool = false - var coordinatorError: NSError? = nil - var error: NSError? = nil - NSFileCoordinator() - .coordinate(readingItemAt: fileURL, options: .withoutChanges, error: &coordinatorError) { url in - do { - if !fileManager.fileExists(atPath: targetPath, isDirectory:&isDirectory) { - try fileManager.createDirectory(atPath: targetPath, withIntermediateDirectories: true, attributes: nil) - // Check to see if file exists, move file, error handling - } - - let filename = fileURL.lastPathComponent - let newFilePath = (targetPath as NSString).appendingPathComponent(filename) - if fileManager.fileExists(atPath: newFilePath) { - try fileManager.removeItem(atPath: newFilePath) - } - - try fileManager.moveItem(atPath: fileURL.path, toPath: newFilePath) - } catch let err { - error = err as NSError - } - } - - fileURL.stopAccessingSecurityScopedResource() - - if let error = (error != nil) ? error : coordinatorError { - throw error - } - } - - private func signalEnumerator(for container: NSFileProviderItemIdentifier) { - guard let domain = self.domain, - let fpm = NSFileProviderManager(for: domain) else { - return - } - - fpm.signalEnumerator(for: container, completionHandler: { error in - BlinkLogger("signalEnumerator").info("Enumerator Signaled with \(error ?? "no error")") - }) - } - - deinit { - print("OOOOUUUTTTTT!!!!!") - } -} diff --git a/BlinkFileProvider/FileProviderItem.swift b/BlinkFileProvider/FileProviderItem.swift new file mode 100644 index 000000000..ae1cb79e1 --- /dev/null +++ b/BlinkFileProvider/FileProviderItem.swift @@ -0,0 +1,227 @@ +////////////////////////////////////////////////////////////////////////////////// +// +// B L I N K +// +// Copyright (C) 2016-2024 Blink Mobile Shell Project +// +// This file is part of Blink. +// +// Blink is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Blink is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Blink. If not, see . +// +// In addition, Blink is also subject to certain additional terms under +// GNU GPL version 3 section 7. +// +// You should have received a copy of these additional terms immediately +// following the terms and conditions of the GNU General Public License +// which accompanied the Blink Source Code. If not, see +// . +// +//////////////////////////////////////////////////////////////////////////////// + + +import BlinkFiles +import FileProvider +import UniformTypeIdentifiers + +class FileProviderItem: NSObject, NSFileProviderItem { + let blinkIdentifier: BlinkFileItemIdentifier + private let attributes: BlinkFiles.FileAttributes + + init(blinkIdentifier: BlinkFileItemIdentifier, attributes: BlinkFiles.FileAttributes) { + self.blinkIdentifier = blinkIdentifier + + let fileType = attributes[.type] as? FileAttributeType + if fileType == .typeSymbolicLink, + let targetAttributes = attributes[.symbolicLinkTargetInfo] as? BlinkFiles.FileAttributes { + // For purposes of the Provider, the attributes the attributes are those of the target, except the symlink name. + var attrs = targetAttributes + attrs[.name] = attributes[.name] as! String + self.attributes = attrs + } else { + self.attributes = attributes + } + } + + var itemIdentifier: NSFileProviderItemIdentifier { + return blinkIdentifier.itemIdentifier + } + + var parentItemIdentifier: NSFileProviderItemIdentifier { + return blinkIdentifier.parentIdentifier + } + + var parentPath: String { + return blinkIdentifier.parentPath + } + + var itemVersion: NSFileProviderItemVersion { + let ts = (attributes[.modificationDate] as? NSDate)?.timeIntervalSince1970 ?? 0 + return NSFileProviderItemVersion(contentVersion: "\(ts)".data(using: .utf8)!, + metadataVersion: "\(ts)".data(using: .utf8)!) + } + + var filename: String { + if blinkIdentifier.itemIdentifier == .rootContainer { + return "/" + } + return attributes[.name] as! String + } + + var contentType: UTType { + guard let type = self.attributes[.type] as? FileAttributeType else { + return UTType.data + } + + if type == .typeDirectory { + return UTType.directory + } + + let pathExtension = (filename as NSString).pathExtension + if let type = UTType(filenameExtension: pathExtension) { + return type + } else { + return UTType.item + } + } + + var documentSize: NSNumber? { attributes[.size] as? NSNumber } + var creationDate: Date? { attributes[.creationDate] as? Date } + var contentModificationDate: Date? { attributes[.modificationDate] as? Date } + + var permissions: PosixPermissions? { + guard let perm = attributes[.posixPermissions] as? NSNumber else { + return nil + } + return PosixPermissions(rawValue: perm.int16Value) + } + + var capabilities: NSFileProviderItemCapabilities { + guard let permissions = self.permissions else { + return [] + } + + var c = NSFileProviderItemCapabilities() + if contentType == .directory || contentType == .folder { + c.formUnion(.allowsAddingSubItems) + if permissions.contains(.ux) { + c.formUnion([.allowsContentEnumerating, .allowsReading]) + } + if permissions.contains(.uw) { + c.formUnion([.allowsRenaming, .allowsDeleting]) + } + } else { + if permissions.contains(.ur) { + c.formUnion(.allowsReading) + } + if permissions.contains(.uw) { + c.formUnion([.allowsWriting, .allowsDeleting, .allowsRenaming, .allowsReparenting]) + } + } + + return c + } +} + +extension FileProviderItem { + func isContentMoreRecent(than otherVersion: NSFileProviderItemVersion) -> Bool { + guard let currentTimestamp = Double(String(data: itemVersion.contentVersion, encoding: .utf8) ?? ""), + let otherTimestamp = Double(String(data: otherVersion.contentVersion, encoding: .utf8) ?? "") else { + return false + } + return currentTimestamp > otherTimestamp + } +} + +class BlinkFileItemIdentifier { + // Idea is to exchange the NSFileProviderItemIdentifier for a proper BlinkItemIdentifier, which + // covers this previous functionality. + let itemIdentifier: NSFileProviderItemIdentifier + let parentIdentifier: NSFileProviderItemIdentifier + let path: String + let name: String + let parentPath: String + var rawValue: String { itemIdentifier.rawValue } + var description: String { path.isEmpty ? "root" : path} + + static let rootContainer = BlinkFileItemIdentifier(with: .rootContainer, name: "", parentIdentifier: .rootContainer, parentPath: "") + + init(with rawIdentifier: NSFileProviderItemIdentifier, name: String, parentIdentifier: NSFileProviderItemIdentifier, parentPath: String) { + self.itemIdentifier = rawIdentifier + self.name = name + self.parentIdentifier = parentIdentifier + self.parentPath = parentPath + self.path = (parentPath as NSString).appendingPathComponent(name) + } + + convenience init(with rawIdentifier: NSFileProviderItemIdentifier, name: String, parent: BlinkFileItemIdentifier) { + self.init(with: rawIdentifier, name: name, parentIdentifier: parent.itemIdentifier, parentPath: parent.path) + } + + func isRoot() -> Bool { itemIdentifier == .rootContainer } + + func renamedItem() -> BlinkFileItemIdentifier { + let pattern = #"^(.*?)(?: (\d+))?$"# + let regex = try! NSRegularExpression(pattern: pattern) + let range = NSRange(name.startIndex..., in: name) + + if let match = regex.firstMatch(in: name, range: range) { + let base = (match.range(at: 1).location != NSNotFound) ? String(name[Range(match.range(at: 1), in: name)!]).trimmingCharacters(in: .whitespaces) : name + let number = (match.range(at: 2).location != NSNotFound) ? (Int(name[Range(match.range(at: 2), in: name)!]) ?? 1) + 1 : 2 + return BlinkFileItemIdentifier(with: self.itemIdentifier, + name: "\(base) \(number)", + parentIdentifier: self.parentIdentifier, + parentPath: self.parentPath) + } + return BlinkFileItemIdentifier(with: self.itemIdentifier, + name: "\(name) 2", + parentIdentifier: self.parentIdentifier, + parentPath: self.parentPath) + } + + static func generate(name: String, parent: BlinkFileItemIdentifier, isSymbolicLink: Bool = false) -> BlinkFileItemIdentifier { + var identifier = NSFileProviderItemIdentifier.shortUUID() + if isSymbolicLink { + identifier = NSFileProviderItemIdentifier(rawValue: "@" + identifier.rawValue.dropFirst()) + } + return BlinkFileItemIdentifier(with: identifier, name: name, parent: parent) + } +} + +extension NSFileProviderItemIdentifier { + static func shortUUID() -> Self { + NSFileProviderItemIdentifier(rawValue: String(UUID().uuidString.prefix(13))) + } + + func isSymbolicLink() -> Bool { self.rawValue.starts(with: "@") } +} + +struct PosixPermissions: OptionSet { + let rawValue: Int16 // It is really a CShort + + // rwx + // u[ser] + static let ur = PosixPermissions(rawValue: 1 << 8) + static let uw = PosixPermissions(rawValue: 1 << 7) + static let ux = PosixPermissions(rawValue: 1 << 6) + + // g[roup] + static let gr = PosixPermissions(rawValue: 1 << 5) + static let gw = PosixPermissions(rawValue: 1 << 4) + static let gx = PosixPermissions(rawValue: 1 << 3) + + // o[ther] + static let or = PosixPermissions(rawValue: 1 << 2) + static let ow = PosixPermissions(rawValue: 1 << 1) + static let ox = PosixPermissions(rawValue: 1 << 0) +} diff --git a/BlinkFileProvider/FileProviderReplicatedEnumerator.swift b/BlinkFileProvider/FileProviderReplicatedEnumerator.swift new file mode 100644 index 000000000..d9867f366 --- /dev/null +++ b/BlinkFileProvider/FileProviderReplicatedEnumerator.swift @@ -0,0 +1,699 @@ +////////////////////////////////////////////////////////////////////////////////// +// +// B L I N K +// +// Copyright (C) 2016-2024 Blink Mobile Shell Project +// +// This file is part of Blink. +// +// Blink is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Blink is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Blink. If not, see . +// +// In addition, Blink is also subject to certain additional terms under +// GNU GPL version 3 section 7. +// +// You should have received a copy of these additional terms immediately +// following the terms and conditions of the GNU General Public License +// which accompanied the Blink Source Code. If not, see +// . +// +//////////////////////////////////////////////////////////////////////////////// + +import BlinkFiles +import Combine +import FileProvider +import SQLite + +class FileProviderReplicatedEnumerator: NSObject, NSFileProviderEnumerator { + let blinkIdentifier: BlinkFileItemIdentifier + private let anchor = NSFileProviderSyncAnchor("an anchor".data(using: .utf8)!) + private let log: BlinkLogger + private var enumerateItemsCancellable: AnyCancellable? = nil + private var tryMakeActiveEnumerator = false + private var isActiveEnumerator = false + + // Made weak just in case the WorkingSet still retains any enumerators before shut down + // (although what we see is that the FP is always cleaning up). + private weak var workingSet: WorkingSet? = nil + private let connection: FilesTranslatorConnection + + private var itemTranslator: TranslatorPublisher { + let path = blinkIdentifier.path + let identifier = self.blinkIdentifier.itemIdentifier + return connection.rootTranslator + .flatMap { path.isEmpty ? .just($0.clone()) : + $0.cloneWalkTo(path) + .mapError { _ in NSError.fileProviderErrorForNonExistentItem(withIdentifier: identifier) }.eraseToAnyPublisher() + } + .eraseToAnyPublisher() + } + + public init(for blinkIdentifier: BlinkFileItemIdentifier, + workingSet: WorkingSet, + connection: FilesTranslatorConnection, + logger: BlinkLogger) { + self.log = logger + self.blinkIdentifier = blinkIdentifier + self.connection = connection + self.workingSet = workingSet + + super.init() + } + + func makeActiveEnumerator(needsEnumeration: Bool = true) throws { + tryMakeActiveEnumerator = true + if let workingSet = workingSet, + try workingSet.addToActiveEnumerators(self, itemIdentifier: blinkIdentifier.itemIdentifier, needsEnumeration: needsEnumeration) { + self.log.info("Enumerator is Active in WorkingSet") + tryMakeActiveEnumerator = false + isActiveEnumerator = true + } else { + self.log.info("Not part of WorkingSet yet") + } + } + + public func invalidate() { + self.log.info("invalidate") + if isActiveEnumerator { + self.workingSet?.removeFromActiveEnumerators(self) + } + self.workingSet = nil + self.enumerateItemsCancellable = nil + } + + public func enumerateItems(for observer: NSFileProviderEnumerationObserver, startingAt page: NSFileProviderPage) { + log.info("enumerateItems") + + guard let workingSet = workingSet else { + log.error("enumerateItems for invalidated enumerator (no workingSet)") + observer.finishEnumeratingWithError(NSFileProviderError(errorCode: 100, + errorDescription: "Invalid enumerator", + failureReason: "enumerateItems for invalidated enumerator (no workingSet)")) + return + } + /* + If this is an enumerator for a directory, the root container or all directories: + - perform a server request to fetch directory contents + If this is an enumerator for the active set: + - perform a server request to update your local database + - fetch the active set from your local database + + - inform the observer about the items returned by the server (possibly multiple times) + - inform the observer that you are finished with this page + */ + + enumerateItemsCancellable = self.allItems() + .tryMap { itemsAttributes -> [FileProviderItem] in + try workingSet.commitItemsInContainer(self.blinkIdentifier, itemsAttributes: itemsAttributes) + } + .sink ( + receiveCompletion: { completion in + switch completion { + case .failure(let error): + self.log.error("enumerateItems Error \(error)") + observer.finishEnumeratingWithError(error) + case .finished: + if self.tryMakeActiveEnumerator { try? self.makeActiveEnumerator(needsEnumeration: false) } + observer.finishEnumerating(upTo: nil) + } + }, + receiveValue: { + self.log.info("Enumerated \($0.count) items") + // Skip "." as internal. If you enumerate ".", it is seen as a container by the System, and it may + // end up in a recursion loop itself. + observer.didEnumerate($0.filter { $0.filename != "." }) + }) + } + + public func enumerateChanges(for observer: NSFileProviderChangeObserver, from anchor: NSFileProviderSyncAnchor) { + /* + - query the server for updates since the passed-in sync anchor + + If this is an enumerator for the active set: + - note the changes in your local database + + - inform the observer about item deletions and updates (modifications + insertions) + - inform the observer when you have finished enumerating up to a subsequent sync anchor + */ + log.info("No changes at enumerator") + // I wouldn't expect this one to be called. But we could return it + // based on the current state of the Accumulator - as previous states shouldn't be called either. + // Or, if we to return from a previous Accumulator, we could return it based on database and doing individual stat for contents. + observer.finishEnumeratingChanges(upTo: anchor, moreComing: false) + } + + func allItems() -> AnyPublisher<[BlinkFiles.FileAttributes], Error> { + itemTranslator + .flatMap { $0.isDirectory ? $0.directoryFilesAndAttributesWithTargetLinks() : AnyPublisher($0.stat().collect()) } + .map { allAttributes -> [BlinkFiles.FileAttributes] in + allAttributes.compactMap { fileAttributes -> BlinkFiles.FileAttributes? in + let fileName = fileAttributes[.name] as! String + if fileName == ".." || + // This is recognized as a special directory by the system, we skip it. + fileName == ".Trash" || + fileName.starts(with: ".blink.tmp.") { + return nil + } + return fileAttributes + } + } + .eraseToAnyPublisher() + } + + public func currentSyncAnchor(completionHandler: @escaping (NSFileProviderSyncAnchor?) -> Void) { + log.info("currentSyncAnchor requested") + completionHandler(nil) + // if let workingSet = workingSet, + // ((try? workingSet.isContentInSet(self.blinkIdentifier.itemIdentifier)) ?? false), + // let anchor = self.workingSet?.anchor { + // log.info("currentSyncAnchor \(anchor.string)") + // completionHandler(anchor) + // } else { + // completionHandler(nil) + // } + } + + deinit { + log.info("cleared") + } +} + +public class WorkingSetEnumerator: NSObject, NSFileProviderEnumerator { + private let log: BlinkLogger + private let workingSet: WorkingSet + + public func invalidate() { + log.info("invalidate") + } + + public func enumerateItems(for observer: any NSFileProviderEnumerationObserver, startingAt page: NSFileProviderPage) { + log.info("enumerateItems") + /* + If this is an enumerator for the active set: + - perform a server request to update your local database + - fetch the active set from your local database + */ + //observer.didEnumerate([FileProviderItem(identifier: NSFileProviderItemIdentifier("a file"))]) + + observer.finishEnumerating(upTo: nil) + } + + public func enumerateChanges(for observer: NSFileProviderChangeObserver, from anchor: NSFileProviderSyncAnchor) { + log.info("enumerateChanges from \(anchor.string)") + self.workingSet.enumerateChanges(for: observer, from: anchor) + } + + public func currentSyncAnchor(completionHandler: @escaping (NSFileProviderSyncAnchor?) -> Void) { + let anchor = workingSet.anchor + log.info("currentSyncAnchor \(anchor.string)") + completionHandler(anchor) + } + + init(workingSet: WorkingSet, logger: BlinkLogger) { + self.log = logger + self.workingSet = workingSet + + super.init() + + log.info("requested") + } + + deinit { + log.info("deinit") + } +} + +public class WorkingSet { + private let fpm: NSFileProviderManager? + private let pollCoordinator = PollCoordinator() + private var timer: DispatchSourceTimer? = nil + private var timerIntervalInSeconds = 0 + private let log: BlinkLogger + private var prepareChangesCancellable: AnyCancellable? = nil + private var prepareChangesTick = 0 + private let changesQueue = DispatchQueue(label: "sh.blink.BlinkFileProvider.WorkingSet") + + private var anchorIteration: Int + private var anchorVersion: String + var anchor: NSFileProviderSyncAnchor { + NSFileProviderSyncAnchor("\(anchorVersion)-\(anchorIteration)".data(using: .utf8)!) + } + private var changes = ItemsChanged() + private let db: WorkingSetDatabase + + private var itemsInCommit = Set() + + init(domain: NSFileProviderDomain?, db: WorkingSetDatabase, logger: BlinkLogger) throws { + self.log = logger + + if let domain = domain { + self.fpm = NSFileProviderManager(for: domain)! + } else { + self.fpm = nil + } + + self.db = db + self.anchorIteration = try db.newestAnchor() + self.anchorVersion = db.anchorVersion + + self.log.info("Initialized") + } + + deinit { + self.log.debug("Deinit") + } + + func resumeChangesTimerEvery(seconds: Int) { + let timer = DispatchSource.makeTimerSource(flags: [], queue: changesQueue) + timer.setEventHandler { [weak self] in + guard let self = self else { + return + } + + self.log.info("Timer triggered") + self.prepareChangesAndSignalEnumerator() + } + + timer.schedule(deadline: .now(), repeating: .seconds(seconds)) + timer.resume() + + self.timer = timer + self.timerIntervalInSeconds = seconds + } + + func addToActiveEnumerators(_ enumerator: FileProviderReplicatedEnumerator, + itemIdentifier: NSFileProviderItemIdentifier, + needsEnumeration: Bool) throws -> Bool { + // What happens if an Container is empty? Then it will never be part of the WorkingSet? + // Always add the ".", but do not publish it. + if try self.isContentInSet(itemIdentifier) { + return changesQueue.sync { + self.pollCoordinator.addActiveEnumerator(enumerator) + + if needsEnumeration && prepareChangesCancellable == nil { + self.cancelChanges() + if timer != nil { + resumeChangesTimerEvery(seconds: self.timerIntervalInSeconds) + } + } + return true + } + } + return false + } + + func removeFromActiveEnumerators(_ enumerator: FileProviderReplicatedEnumerator) { + self.log.info("Removing enumerator \(enumerator)") + + changesQueue.sync { self.pollCoordinator.removeActiveEnumerator(enumerator) } + } + + @objc func prepareChangesAndSignalEnumerator() { + prepareChanges { [weak self] (changes) in + guard let self = self else { + return + } + + if changes.isEmpty { + self.log.info("No changes.") + return + } + + // Only apply changes if previous batch was applied. + if self.changes.isEmpty { + self.anchorIteration += 1 + self.changes = changes + } + + self.signalEnumerator() + } + } + + func prepareChanges(onCompletion: @escaping ((ItemsChanged) -> Void)) { + changesQueue.async { [weak self] in + guard let self = self else { + return + } + + self.log.info("Preparing changes. \(self.itemsInCommit.count) items in commit.") + + // The WorkingSet may step on its own while enumerating changes and with long running + // operations in the background. The backoff control tries to avoid that. + if prepareChangesCancellable != nil { + if prepareChangesTick < 3 { + prepareChangesTick += 1 + } else { + prepareChangesTick = 0 + prepareChangesCancellable?.cancel() + prepareChangesCancellable = nil + } + } + + let enumerators = self.pollCoordinator.nextBatch() + + if enumerators.isEmpty { + onCompletion(ItemsChanged()) + return + } + + self.prepareChangesCancellable = enumerators.publisher + .compactMap { enumerator in + self.itemsInCommit.contains { $0.hasPrefix(enumerator.blinkIdentifier.path + "/") } ? nil : enumerator + } + .flatMap { enumerator -> AnyPublisher<([ItemRow], [FileProviderItem]), Never> in + let container = enumerator.blinkIdentifier + let dbItemsPublisher = Just(container.itemIdentifier) + .tryMap { + let itemRows = try self.db.items(in: $0) + self.log.debug("\(container.description) has \(itemRows.count) on DB") + return itemRows + } + let allItemsPublisher = enumerator.allItems() + .tryMap { + self.log.debug("\(container.description) received \($0.count) from source.") + return try self.matchOrGenerateItemAttributesInContainer(container, itemsAttributes: $0) + } + + return Publishers.Zip(dbItemsPublisher, allItemsPublisher) + .catch { error -> AnyPublisher<([ItemRow], [FileProviderItem]), Never> in + // Skip an enumerator if it failed. + self.log.error("prepareChanges for \(container.description) failed - \(error)") + return Just(([], [])).eraseToAnyPublisher() + } + .eraseToAnyPublisher() + } + .receive(on: self.changesQueue) + .map { (rows: [ItemRow], items: [FileProviderItem]) -> ItemsChanged in + let deletedRows = rows.filter { row in + !items.contains { $0.filename == row.name } + } + + let updatedItems = items.compactMap { item in + if let row = rows.first(where: { $0.name == item.filename }), + item.itemVersion != row.version { + return item + } + return nil + } + + // Find new items + let newItems = items.filter { item in + !rows.contains { $0.name == item.filename } + } + + let detectedChanges = ItemsChanged(creates: newItems, updates: updatedItems, deletions: deletedRows) + if detectedChanges.hasChanges { + (newItems + updatedItems).forEach { item in + self.log.debug("Item changed: \(item.filename)") + } + deletedRows.forEach { item in + self.log.debug("Deleted \(item.name)") + } + } + return detectedChanges + } + .reduce(ItemsChanged()) { (all: ItemsChanged, next: ItemsChanged) -> ItemsChanged in + return ItemsChanged(creates: all.creates + next.creates, + updates: all.updates + next.updates, + deletions: all.deletions + next.deletions) + } + .sink { + self.log.info("Prepare changes completed") + onCompletion($0) + } + } + } + + + func scheduleDeletionsAndSignalEnumerator(deletions: [ItemRow]) { + // When scheduling changes, we let them sit on top of the prepared changes. + changesQueue.async { [weak self] in + guard let self = self else { + return + } + + self.log.info("Scheduling Deletion changes") + + if self.changes.isEmpty { + self.anchorIteration += 1 + } + + self.changes = ItemsChanged(creates: self.changes.creates, + updates: self.changes.updates, + deletions: deletions + self.changes.deletions) + + self.signalEnumerator() + } + } + + func signalEnumerator() { + self.log.info("signalEnumerator for \(self.anchorIteration) anchor iteration.") + + self.fpm?.signalEnumerator(for: .workingSet) { error in + if let error = error { + self.log.error("signalEnumerator failed after prepareChanges: \(error)") + } + } + } + // The flow for changes is divided in two parts. prepare and commit (changes). We use enumerateChanges as the commit part. + func enumerateChanges(for observer: NSFileProviderChangeObserver, from anchor: NSFileProviderSyncAnchor) { + self.changesQueue.sync { + if anchor == self.anchor { + self.log.debug("enumerateChanges on Same anchor \(anchorIteration)") + observer.finishEnumeratingChanges(upTo: anchor, moreComing: false) + return + } else if self.anchor.iteration == anchor.iteration + 1 { + self.log.debug("enumerateChanges coordinate next step") + + do { + let createRows = self.changes.creates.map { ItemRow.from($0, at: self.anchorIteration) } + let updateRows = self.changes.updates.map { ItemRow.from($0, at: self.anchorIteration) } + + let deletedRows = try db.updateChangedItems(createRows: createRows, updateRows: updateRows, deleteRows: self.changes.deletions) + let deletions = deletedRows.map { $0.item } + + // Filter "." as internal. + let updatedItems = (changes.creates + changes.updates).filter { $0.filename != "." } + observer.didDeleteItems(withIdentifiers: deletions) + observer.didUpdate(updatedItems) + observer.finishEnumeratingChanges(upTo: self.anchor, moreComing: false) + self.changes = ItemsChanged() + } catch { + self.log.error("enumerateItems error - \(error)") + observer.finishEnumeratingWithError(error) + } + + return + } else { + self.log.error("SyncAnchor expired. Requested \(anchor.string). WorkingSet at \(self.anchor.string)") + // The system is in an incorrect state. Reset. + observer.finishEnumeratingWithError(NSError(domain: NSFileProviderErrorDomain, + code: NSFileProviderError.syncAnchorExpired.rawValue, + userInfo: nil)) + return + } + } + +// // Not necessary atm. +// func enumerateItems(for observer: any NSFileProviderEnumerationObserver, startingAt page: NSFileProviderPage) { +// log.info("enumerateItems") +// /* +// If this is an enumerator for the active set: +// - perform a server request to update your local database +// - fetch the active set from your local database +// */ +// // Stop timers. Try not to Commit and PrepareChanges at the same time. +// // I don't like we need the rootContainer. And then walk to each container to figure out if it still exists. +// // Should the other container enumerators also check for that? +// // Get all the containers. +// Just(self.db) +// .tryMap { try $0.containersInSet() } +// .flatMap { containers in + +// } +// // Create enumerators and enumerate one by one. +// // Commit +// // Restart timers. +// } + } +} + +// WorkingSet + DB functions +extension WorkingSet { + func blinkIdentifier(for identifier: NSFileProviderItemIdentifier) throws -> BlinkFileItemIdentifier? { + if identifier == .rootContainer { + return .rootContainer + } + + guard let row = try db.item(identifier) else { + // Return the item does not exist, let other layers decide the error for the extension. + // throw NSError.fileProviderErrorForNonExistentItem(withIdentifier: identifier) + return nil + } + return row.blinkIdentifier() + } + + func blinkIdentifier(itemName: String, container: BlinkFileItemIdentifier) throws -> BlinkFileItemIdentifier? { + guard let row = try db.item(from: itemName, containerIdentifier: container.itemIdentifier) else { + return nil + } + return row.blinkIdentifier() + } + + func isItemInSet(_ identifier: NSFileProviderItemIdentifier) throws -> Bool { + try db.isItemInSet(identifier) + } + + func isContentInSet(_ containerIdentifier: NSFileProviderItemIdentifier) throws -> Bool { + try db.isContentInSet(containerIdentifier) + } + + func commitItemsInContainer(_ container: BlinkFileItemIdentifier, itemsAttributes: [BlinkFiles.FileAttributes]) throws -> [FileProviderItem] { + log.debug("Commit \(itemsAttributes.count) items at \(container.path)") + + // Replace Rows with new set. + let items = try matchOrGenerateItemAttributesInContainer(container, itemsAttributes: itemsAttributes) + + let newRows = items.map { ItemRow.from($0, at: self.anchorIteration) } + + let _ = try self.db.updateItemsInContainer(container, items: newRows) + + return items + } + + private func matchOrGenerateItemAttributesInContainer(_ container: BlinkFileItemIdentifier, itemsAttributes: [BlinkFiles.FileAttributes]) throws -> [FileProviderItem] { + let previousRows = try self.db.items(in: container.itemIdentifier) + + return itemsAttributes.map { itemAttrs in + let itemName = itemAttrs[.name] as! String + let fileType = itemAttrs[.type] as? FileAttributeType + let blinkIdentifier = if let existingRow = previousRows.first(where: { $0.name == itemName }) { + BlinkFileItemIdentifier(with: existingRow.item, name: itemName, parent: container) + } else { + BlinkFileItemIdentifier.generate(name: itemName, parent: container, isSymbolicLink: fileType == .typeSymbolicLink) + } + + return FileProviderItem(blinkIdentifier: blinkIdentifier, attributes: itemAttrs) + } + } + + func commitItemInSet(itemPath: String, itemPublisher: () -> AnyPublisher) -> + AnyPublisher { + return changesQueue.sync { + log.debug("Committing item \(itemPath)") + itemsInCommit.insert(itemPath) + return itemPublisher() + .tryMap { item in + let row = ItemRow.from(item, at: self.anchorIteration) + let _ = try self.db.updateItem(row) + return item + } + .handleEvents( + receiveCompletion: { _ in self.changesQueue.async { self.itemsInCommit.remove(itemPath) } }, + receiveCancel: { self.changesQueue.async { self.itemsInCommit.remove(itemPath) } } + ) + .eraseToAnyPublisher() + } + } + + func commitItemInSet(_ item: FileProviderItem) throws { + log.debug("Committing item \(item.blinkIdentifier.path)") + let row = ItemRow.from(item, at: self.anchorIteration) + let _ = try self.db.updateItem(row) + } + + func invalidate() { + cancelTimers() + cancelChanges() + } + + private func cancelChanges() { + self.prepareChangesCancellable?.cancel() + self.prepareChangesCancellable = nil + } + + private func cancelTimers() { + self.timer?.cancel() + self.timer = nil + } +} + +enum ItemChange { + case Update(FileProviderItem) + case Delete(ItemRow) +} + +struct ItemsChanged { + var creates: [FileProviderItem] + var updates: [FileProviderItem] + var deletions: [ItemRow] + + init() { + self.creates = [] + self.updates = [] + self.deletions = [] + } + + init(creates: [FileProviderItem], updates: [FileProviderItem], deletions: [ItemRow]) { + self.creates = creates + self.updates = updates + self.deletions = deletions + } + + var hasChanges: Bool { + self.creates.count > 0 || self.updates.count > 0 || self.deletions.count > 0 + } + + var isEmpty: Bool { + !self.hasChanges + } +} + +extension NSFileProviderSyncAnchor { + var iteration: Int { + Int(self.string + .components(separatedBy: "-")[1])! + } + + var string: String { + String(data: self.rawValue, encoding: .utf8)! + } +} + +class PollCoordinator { + private var activeEnumerators: [FileProviderReplicatedEnumerator] = [] + + func addActiveEnumerator(_ enumerator: FileProviderReplicatedEnumerator) { + // Observed that the provider may add the enumerator more than once while it transitions. + // That is ok, work with the instance, and we could filter later. + guard !activeEnumerators.contains(where: { enumerator === $0 }) else { return } + + // ActiveEnumerators are open/active folders by the user, were changes may happen, + // so we keep them open. It is rare that it would be higher than this number, but we + // found a case where an operation would create a lot of enumerators without killing them. + // This is a simple way to rotate them without impacting performance. + if activeEnumerators.count == 5 { + _ = activeEnumerators.popLast() + } + + activeEnumerators.append(enumerator) + } + + func removeActiveEnumerator(_ enumerator: FileProviderReplicatedEnumerator) { + activeEnumerators.removeAll(where: { enumerator === $0 }) + } + + func nextBatch() -> [FileProviderReplicatedEnumerator] { + return activeEnumerators + } +} diff --git a/BlinkFileProvider/FileProviderReplicatedExtension+Helpers.swift b/BlinkFileProvider/FileProviderReplicatedExtension+Helpers.swift new file mode 100644 index 000000000..76965930b --- /dev/null +++ b/BlinkFileProvider/FileProviderReplicatedExtension+Helpers.swift @@ -0,0 +1,790 @@ +////////////////////////////////////////////////////////////////////////////////// +// +// B L I N K +// +// Copyright (C) 2016-2024 Blink Mobile Shell Project +// +// This file is part of Blink. +// +// Blink is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Blink is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Blink. If not, see . +// +// In addition, Blink is also subject to certain additional terms under +// GNU GPL version 3 section 7. +// +// You should have received a copy of these additional terms immediately +// following the terms and conditions of the GNU General Public License +// which accompanied the Blink Source Code. If not, see +// . +// +//////////////////////////////////////////////////////////////////////////////// + + +import BlinkFiles +import Combine +import FileProvider + +extension FileProviderReplicatedExtension { + func _statItem(_ blinkIdentifier: BlinkFileItemIdentifier, + log: BlinkLogger, + completionHandler: @escaping (NSFileProviderItem?, Error?) -> Void) -> Progress { + let progress = Progress(totalUnitCount: 1) + + var itemCancellable: AnyCancellable? = self._itemTranslator(for: blinkIdentifier) + .flatMap { $0.stat() } + .tryMap { + let item = FileProviderItem(blinkIdentifier: blinkIdentifier, attributes: $0) + try self.workingSet.commitItemInSet(item) + return item + } + .sink(receiveCompletion: { completion in + if case let .failure(error) = completion { + log.info("Failed - \(error)") + completionHandler(nil, error) + } + }, receiveValue: { (item: FileProviderItem) in + progress.completedUnitCount = 1 + log.info("Found \(item.parentPath) \(item.filename)") + completionHandler(item, nil) + }) + + progress.cancellationHandler = { + itemCancellable?.cancel() + itemCancellable = nil + completionHandler(nil, NSError(domain: NSCocoaErrorDomain, code: NSUserCancelledError)) + } + return progress + } + + func _downloadItem(fileItem: FileProviderItem, log: BlinkLogger, completionHandler: @escaping (URL?, NSFileProviderItem?, Error?) -> Void) -> Progress { + log.info("Downloading file \(fileItem.filename)...") + let progress = Progress(totalUnitCount: fileItem.documentSize?.int64Value ?? -1) + + let srcTranslator = self._itemTranslator(for: fileItem.blinkIdentifier) + let destinationURL = self._makeTemporaryFile() + let destTranslator = Local().cloneWalkTo(destinationURL.path) + + var totalWritten: Int64 = 0 + var copyCancellable: AnyCancellable? = srcTranslator + .flatMap { fileTranslator in + destTranslator.flatMap { $0.copy(from: [fileTranslator], + args: self.copyArguments) } + } + .sink( + receiveCompletion: { completion in + switch completion { + case .finished: + log.info("Download Completed") + progress.completedUnitCount = progress.totalUnitCount + completionHandler(destinationURL, fileItem, nil) + case .failure(let error): + log.error("Download file error: \(error)") + completionHandler(nil, nil, error) + } + }, + receiveValue: { copyProgressInfo in + log.debug("Download progress: \(copyProgressInfo)") + progress.totalUnitCount = Int64(copyProgressInfo.size) + totalWritten += Int64(copyProgressInfo.written) + progress.completedUnitCount = totalWritten + } + ) + + progress.cancellationHandler = { + log.warn("Download cancelled by user") + copyCancellable?.cancel() + copyCancellable = nil + completionHandler(nil, nil, NSError(domain: NSCocoaErrorDomain, code: NSUserCancelledError)) + } + + return progress + } + + private func _makeTemporaryFile() -> URL { + let url = temporaryDirectoryURL.appending(path: "\(UUID().uuidString)") + // Shouldn't fail at this point (random file on temporary directory) + let _ = FileManager.default.createFile(atPath: url.path, contents: nil) + return url + } + + func _createItem(basedOn itemTemplate: NSFileProviderItem, + inParent parentIdentifier: BlinkFileItemIdentifier, + fields: NSFileProviderItemFields, + contents url: URL, + options: NSFileProviderCreateItemOptions = [], + request: NSFileProviderRequest, + log: BlinkLogger, + completionHandler: @escaping (NSFileProviderItem?, NSFileProviderItemFields, Bool, Error?) -> Void) -> Progress { + let fileName = itemTemplate.filename + let itemPath = (parentIdentifier.path as NSString).appendingPathComponent(fileName) + log.info("Creating file \(itemPath)...") + + let tmpFileName = ".blink.tmp.\(url.lastPathComponent)" + + let sourceTranslator = Local().cloneWalkTo(url.path) + let destTranslator = self.rootTranslator + //let destTranslator = self._itemTranslator(for: parentIdentifier) + + var totalWritten: Int64 = 0 + + let totalProgress = Progress(totalUnitCount: 110) + let uploadProgress = Progress(totalUnitCount: itemTemplate.documentSize??.int64Value ?? -1) + let statProgress = Progress(totalUnitCount: 10) + totalProgress.addChild(uploadProgress, withPendingUnitCount: 100) + totalProgress.addChild(statProgress, withPendingUnitCount: 10) + + var copyCancellable: AnyCancellable? = + self._ensureCanCreateFile(name: fileName, + containerIdentifier: parentIdentifier, + updateAlreadyExisting: options.contains(.mayAlreadyExist), + log: log) + .flatMap { _ -> CopyProgressInfoPublisher in + log.debug("Copy file \(tmpFileName)...") + return Publishers.Zip(sourceTranslator, destTranslator) + .flatMap { (sourceFile, destination) -> CopyProgressInfoPublisher in + destination.copy(from: sourceFile, newName: tmpFileName, args: self.copyArguments) + }.eraseToAnyPublisher() + } + .filter { copyProgressInfo in + log.debug("Upload progress: \(copyProgressInfo.name), written: \(totalWritten), size: \(copyProgressInfo.size)") + totalWritten += Int64(copyProgressInfo.written) + uploadProgress.totalUnitCount = Int64(copyProgressInfo.size) + uploadProgress.completedUnitCount = totalWritten + return copyProgressInfo.size == totalWritten + } + .first() + .flatMap { _ in + destTranslator.flatMap { $0.cloneWalkTo(tmpFileName) } + .flatMap { tmpFileTranslator in + log.debug("Attributes from \(tmpFileName) to \(fileName)") + var newAttributes: BlinkFiles.FileAttributes = [:] + newAttributes[.name] = (self.connection.rootTranslatorPath as NSString) + .appendingPathComponent(itemPath) + + if fields.contains(.creationDate) { + newAttributes[.creationDate] = itemTemplate.creationDate! + } + if fields.contains(.contentModificationDate) { + newAttributes[.modificationDate] = itemTemplate.contentModificationDate! + } + + return self.workingSet.commitItemInSet(itemPath: itemPath) { + tmpFileTranslator.wstat(newAttributes) + // Rename after upload should succeed. Otherwise, there are problems at the final container, + // and retrying won't fix them. + .mapError { error in + log.error("\(error)") + return NSFileProviderError(.cannotSynchronize) + } + .flatMap { _ in destTranslator.flatMap { $0.cloneWalkTo(itemPath) } } + .flatMap { + log.debug("Fetching \(fileName) attributes") + return $0.stat() + } + .map { + let newIdentifier = BlinkFileItemIdentifier.generate(name: fileName, parent: parentIdentifier) + let createdItem = FileProviderItem(blinkIdentifier: newIdentifier, attributes: $0) + return createdItem + } + .eraseToAnyPublisher() + } + } + } + .sink( + receiveCompletion: { completion in + switch completion { + case .finished: + log.info("Creating file completed.") + case .failure(let error): + log.error("Creating file error: \(error)") + if let error = error as? NSFileProviderError { + completionHandler(nil, fields, false, error) + } else { + completionHandler(nil, fields, false, NSFileProviderError.operationError(dueTo: error)) + } + } + }, + receiveValue: { createdItem in + completionHandler(createdItem, + [], + false, + nil + ) + }) + + totalProgress.cancellationHandler = { + log.warn("Create cancelled by user") + copyCancellable?.cancel() + copyCancellable = nil + completionHandler(nil, [], false, NSError(domain: NSCocoaErrorDomain, code: NSUserCancelledError)) + } + return totalProgress + } + + func _uploadItem(_ fileItem: NSFileProviderItem, + inParent parentIdentifier: BlinkFileItemIdentifier, + originalIdentifier: BlinkFileItemIdentifier, + baseVersion version: NSFileProviderItemVersion, + changedFields: NSFileProviderItemFields, + contents url: URL, + options: NSFileProviderModifyItemOptions = [], + request: NSFileProviderRequest, + log: BlinkLogger, + completionHandler: @escaping (NSFileProviderItem?, NSFileProviderItemFields, Bool, Error?) -> Void) -> Progress { + // NOTE The parent may be in a different location than the current item if it is also reparenting. + let fileName = fileItem.filename + let itemPath = (parentIdentifier.path as NSString).appendingPathComponent(fileName) + log.info("Uploading file \(itemPath)") + + let tmpFileName = ".blink.tmp.\(url.lastPathComponent)" + + let sourceTranslator = Local().cloneWalkTo(url.path) + let destTranslator = self.rootTranslator + //let destTranslator = self._itemTranslator(for: parentIdentifier) + + var totalWritten: Int64 = 0 + + let totalProgress = Progress(totalUnitCount: 110) + let uploadProgress = Progress(totalUnitCount: fileItem.documentSize??.int64Value ?? -1) + let statProgress = Progress(totalUnitCount: 10) + totalProgress.addChild(uploadProgress, withPendingUnitCount: 100) + totalProgress.addChild(statProgress, withPendingUnitCount: 10) + + var copyCancellable: AnyCancellable? = + self._ensureCanReplaceItem(originalIdentifier: originalIdentifier, + containerIdentifier: parentIdentifier, + baseVersion: version, + log: log) + .flatMap { _ -> CopyProgressInfoPublisher in + log.debug("Upload file \(tmpFileName)") + return Publishers.Zip(sourceTranslator, destTranslator) + .flatMap { (sourceFile, destination) in + destination.copy(from: sourceFile, newName: tmpFileName, args: self.copyArguments) + }.eraseToAnyPublisher() + } + .filter { copyProgressInfo in + log.debug("Upload progress: \(copyProgressInfo)") + totalWritten += Int64(copyProgressInfo.written) + uploadProgress.totalUnitCount = Int64(copyProgressInfo.size) + uploadProgress.completedUnitCount = totalWritten + return copyProgressInfo.size == totalWritten + } + .first() + .flatMap { _ in + destTranslator.flatMap { $0.cloneWalkTo(tmpFileName) } + .flatMap { tmpFileTranslator in + log.debug("Attributes from \(tmpFileName) to \(fileName)") + var newAttributes: BlinkFiles.FileAttributes = [:] + newAttributes[.name] = (self.connection.rootTranslatorPath as NSString) + .appendingPathComponent(itemPath) + + if changedFields.contains(.creationDate) { + newAttributes[.creationDate] = fileItem.creationDate! + } + if changedFields.contains(.contentModificationDate) { + newAttributes[.modificationDate] = fileItem.contentModificationDate! + } + + return self.workingSet.commitItemInSet(itemPath: itemPath) { + tmpFileTranslator.wstat(newAttributes) + .mapError { error in + log.error("\(error)") + return NSFileProviderError(.cannotSynchronize) + } + .flatMap { _ in destTranslator.flatMap { $0.cloneWalkTo(itemPath) } } + .flatMap { + log.debug("Fetching \(fileName) attributes") + return $0.stat() + } + .map { + let newIdentifier = BlinkFileItemIdentifier(with: originalIdentifier.itemIdentifier, + name: fileName, + parent: parentIdentifier) + let uploadedItem = FileProviderItem(blinkIdentifier: newIdentifier, attributes: $0) + return uploadedItem + } + .eraseToAnyPublisher() + } + } + } + .sink( + receiveCompletion: { completion in + switch completion { + case .finished: + log.info("Completed") + case .failure(let error): + log.error("Upload file error: \(error)") + if let error = error as? NSFileProviderError { + if error.code == .filenameCollision, + let item = error.userInfo[NSFileProviderErrorItemKey] as? NSFileProviderItem { + completionHandler(item, [], true, nil) + } else { + completionHandler(nil, changedFields, false, error) + } + } else { + completionHandler(nil, changedFields, false, NSFileProviderError.operationError(dueTo: error)) + } + } + }, + receiveValue: { uploadedItem in + // If the operation succeeded, the item has now been uploaded to the parent. + // Commit will replace it and make sure it is unique. + completionHandler(uploadedItem, + [], + false, + nil) + + }) + + totalProgress.cancellationHandler = { + log.warn("Updated cancelled by user") + copyCancellable?.cancel() + copyCancellable = nil + completionHandler(nil, changedFields, false, NSError(domain: NSCocoaErrorDomain, code: NSUserCancelledError)) + } + + return totalProgress + } + + func _createFolder(withName name: String, + inParent parentIdentifier: BlinkFileItemIdentifier, + log: BlinkLogger, + completionHandler: @escaping (NSFileProviderItem?, NSFileProviderItemFields, Bool, Error?) -> Void) -> Progress { + let progress = Progress(totalUnitCount: 1) + let parentPath = parentIdentifier.path + log.info("Create folder \(name) at \(parentPath)") + + let parentTranslatorPublisher = self._itemTranslator(for: parentIdentifier) + + var createDirectoryCancellable: AnyCancellable? = parentTranslatorPublisher + .flatMap { parentTranslator in + parentTranslator.cloneWalkTo(name).catch { _ in + parentTranslator.mkdir(name: name, mode: S_IRWXU | S_IRWXG | S_IRWXO) + } + .tryMap { + if $0.isDirectory { + return $0 + } else { + throw NSFileProviderError(.filenameCollision) + } + } + } + .flatMap { $0.stat() } + .sink( + receiveCompletion: { completion in + switch completion { + case .finished: + log.info("Create Directory completed") + case .failure(let error): + log.error("Create Directory error: \(error)") + completionHandler(nil, [], false, error) + } + }, + receiveValue: { dirAttrs in + progress.completedUnitCount = 1 + do { + // If we fail here (rare), we will be out of sync and eventually trigger a resync. + let createdFolderIdentifier = try self.workingSet.blinkIdentifier(itemName: name, container: parentIdentifier) ?? + BlinkFileItemIdentifier.generate(name: name, parent: parentIdentifier) + let createdItem = FileProviderItem(blinkIdentifier: createdFolderIdentifier, attributes: dirAttrs) + + try self.workingSet.commitItemInSet(createdItem) + completionHandler(createdItem, + [], + false, + nil + ) + } catch { + log.error("Could not commit item to WorkingSet") + completionHandler(nil, [], false, error) + } + }) + + progress.cancellationHandler = { + log.warn("Upload cancelled by user") + createDirectoryCancellable?.cancel() + createDirectoryCancellable = nil + completionHandler(nil, [], false, NSError(domain: NSCocoaErrorDomain, code: NSUserCancelledError)) + } + + return progress + } + + private func _ensureCanCreateFile(name: String, + containerIdentifier: BlinkFileItemIdentifier, + updateAlreadyExisting: Bool, + log: BlinkLogger) -> AnyPublisher { + log.debug("Ensure can create file at \(containerIdentifier.path) with \(name)") + // Ensure the container exists. + // Flag collisions when detected. + return self._itemTranslator(for: containerIdentifier) + .flatMap { + return $0.cloneWalkTo(name) + .map(Optional.some) + .catch { _ in Just(nil) } + } + .flatMap { (translator: Translator?) in + if let translator = translator { + log.debug("File exists, checking flags.") + if updateAlreadyExisting { + return translator.remove() + .map { _ in () } + .eraseToAnyPublisher() + } else { + return translator.stat().tryMap { attributes in + var blinkIdentifier: BlinkFileItemIdentifier + if let existingIdentifier = try self.workingSet.blinkIdentifier(itemName: name, container: containerIdentifier) { + blinkIdentifier = existingIdentifier + } else { + blinkIdentifier = BlinkFileItemIdentifier.generate(name: name, parent: containerIdentifier) + } + let item = FileProviderItem(blinkIdentifier: blinkIdentifier, attributes: attributes) + try self.workingSet.commitItemInSet(item) + throw NSError.fileProviderErrorForCollision(with: item) + }.eraseToAnyPublisher() + } + } else { + log.debug("No file. Can create.") + return Just(()).setFailureType(to: Error.self).eraseToAnyPublisher() + } + } + .eraseToAnyPublisher() + } + + private func _ensureCanReplaceItem(originalIdentifier: BlinkFileItemIdentifier, + containerIdentifier: BlinkFileItemIdentifier, + baseVersion: NSFileProviderItemVersion, + log: BlinkLogger) -> AnyPublisher { + log.debug("Ensure can replace \(originalIdentifier.path)") + // Ensure the container exists. + // Compare versions if the item exists. + return self._itemTranslator(for: containerIdentifier) + .flatMap { _ in + return self.rootTranslator.flatMap { + $0.cloneWalkTo(originalIdentifier.path) + .map(Optional.some) + .catch { _ in Just(nil) } + } + } + .flatMap { (translator: Translator?) -> AnyPublisher in + if let translator = translator { + return translator.stat().flatMap { attributes -> AnyPublisher in + let item = FileProviderItem(blinkIdentifier: originalIdentifier, attributes: attributes) + if item.isContentMoreRecent(than: baseVersion) { + log.debug("Remote is newer, flag to redownload") +// return Fail(error: NSError(domain: NSFileProviderErrorDomain, code: NSFileProviderError.cannotSynchronize.rawValue)).eraseToAnyPublisher() + // The output of this error is that "an item already exists". + // But in this scenario, the upper flow will capture it and indicate to download the new version. + return Fail(error: NSError.fileProviderErrorForCollision(with: item)).eraseToAnyPublisher() + } + log.debug("Local is newer, upload.") + return translator.remove() + .map { _ in () } + .eraseToAnyPublisher() + }.eraseToAnyPublisher() + } else { + log.debug("No file. Upload as new.") + return Just(()).setFailureType(to: Error.self).eraseToAnyPublisher() + } + }.eraseToAnyPublisher() + } + + func _modifyItemAttributes(originalIdentifier: BlinkFileItemIdentifier, + baseVersion version: NSFileProviderItemVersion, + name: String? = nil, + parent: BlinkFileItemIdentifier? = nil, + creationDate: Date? = nil, + modificationDate: Date? = nil, + log: BlinkLogger, + completionHandler: @escaping (NSFileProviderItem?, NSFileProviderItemFields, Bool, Error?) -> Void) + -> Progress { + log.info("Modifying item attributes for \(originalIdentifier.path)") + let progress = Progress(totalUnitCount: 10) + let originalItemPath = originalIdentifier.path + + var newAttributes: BlinkFiles.FileAttributes = [:] + if let creationDate = creationDate { + newAttributes[.creationDate] = creationDate + } + if let modificationDate = modificationDate { + newAttributes[.modificationDate] = modificationDate + } + + var newIdentifier: BlinkFileItemIdentifier = { + let newFileName = name ?? originalIdentifier.name + if let parent = parent { + return BlinkFileItemIdentifier(with: originalIdentifier.itemIdentifier, + name: newFileName, + parent: parent) + } else { + return BlinkFileItemIdentifier(with: originalIdentifier.itemIdentifier, + name: newFileName, + parentIdentifier: originalIdentifier.parentIdentifier, + parentPath: originalIdentifier.parentPath) + } + }() + + var modifyAttributesCancellable: AnyCancellable? = + _ensureCanModifyItemAttributes(originalIdentifier: originalIdentifier, + newItemIdentifier: newIdentifier, + baseVersion: version) + .flatMap { (newItemIdentifier: BlinkFileItemIdentifier, itemTranslator: Translator) -> AnyPublisher in + newIdentifier = newItemIdentifier + + if parent != nil { + // Path needs to be absolute as the provider doesn't take paths relative to root. + newAttributes[.name] = (self.connection.rootTranslatorPath as NSString).appendingPathComponent(newIdentifier.path) + } else if let _ = name { + // Name may have changed for newIdentifier. + newAttributes[.name] = newIdentifier.name + } + + return self.workingSet.commitItemInSet(itemPath: newIdentifier.path) { + return itemTranslator.wstat(newAttributes) + .flatMap { _ in + self.rootTranslator.flatMap { + $0.cloneWalkTo(newIdentifier.path) + } + } + .flatMap { $0.stat() } + .map { + let modifiedItem = FileProviderItem(blinkIdentifier: newIdentifier, attributes: $0) + return modifiedItem + }.eraseToAnyPublisher() + } + } + .sink(receiveCompletion: { completion in + switch completion { + case .finished: + log.info("Attributes modified") + progress.completedUnitCount = progress.totalUnitCount + case .failure(let error): + log.error("Error modifying attributes (wstat): \(error)") + if let error = error as? NSFileProviderError { + completionHandler(nil, [], false, error) + } else { + // FP errors can be mapped to a specific blinkFileProviderError domain? + completionHandler(nil, [], false, NSFileProviderError.operationError(dueTo: error)) + } + } + }, receiveValue: { modifiedItem in + completionHandler(modifiedItem, + [], + false, + nil) + }) + + progress.cancellationHandler = { + log.warn("Modify Attributes cancelled by user") + modifyAttributesCancellable?.cancel() + modifyAttributesCancellable = nil + completionHandler(nil, [], false, NSError(domain: NSCocoaErrorDomain, code: NSUserCancelledError)) + } + + return progress + } + + private func _ensureCanModifyItemAttributes(originalIdentifier: BlinkFileItemIdentifier, + newItemIdentifier: BlinkFileItemIdentifier, + baseVersion: NSFileProviderItemVersion) -> AnyPublisher<(BlinkFileItemIdentifier, Translator), Error> { + let maybeDestinationPublisher = rootTranslator.flatMap { t -> AnyPublisher in + newItemIdentifier.path != originalIdentifier.path + ? t.cloneWalkTo(newItemIdentifier.path) + .map(Optional.some) + .catch { _ in Just(nil).setFailureType(to: Error.self) } + .eraseToAnyPublisher() + : Just(nil).setFailureType(to: Error.self).eraseToAnyPublisher() + } + + return Publishers.Zip(_itemTranslator(for: originalIdentifier), maybeDestinationPublisher) + .flatMap { (originalTranslator: Translator, maybeDestinationTranslator: Translator?) -> AnyPublisher<(BlinkFileItemIdentifier, Translator), Error> in + if let destinationTranslator = maybeDestinationTranslator { + let newItemIdentifier = newItemIdentifier.renamedItem() + return self._ensureCanModifyItemAttributes(originalIdentifier: originalIdentifier, + newItemIdentifier: newItemIdentifier, + baseVersion: baseVersion) + } + + return Just((newItemIdentifier, originalTranslator)).setFailureType(to: Error.self).eraseToAnyPublisher() +// return originalTranslator.stat().tryMap { attributes -> (BlinkFileItemIdentifier, Translator) in +// let item = FileProviderItem(blinkIdentifier: originalIdentifier, attributes: attributes) +// if (item.contentType == .directory || item.contentType == .folder || item.itemVersion.contentVersion == baseVersion.contentVersion) { +// return (newItemIdentifier, originalTranslator) +// } else { +// //throw NSError(domain: NSFileProviderErrorDomain, code: NSFileProviderError.cannotSynchronize.rawValue) +// throw NSError.fileProviderErrorForCollision(with: item) +// } +// }.eraseToAnyPublisher() + }.eraseToAnyPublisher() + } + + private func _itemTranslator(for blinkIdentifier: BlinkFileItemIdentifier) -> TranslatorPublisher { + self.rootTranslator + .flatMap { $0.cloneWalkTo(blinkIdentifier.path).mapError { _ in NSError.fileProviderErrorForNonExistentItem(withIdentifier: blinkIdentifier.itemIdentifier) } } + .eraseToAnyPublisher() + } + + func _createEmptyFile(basedOn itemTemplate: NSFileProviderItem, + inParent parentIdentifier: BlinkFileItemIdentifier, + fields: NSFileProviderItemFields, + options: NSFileProviderCreateItemOptions, + log: BlinkLogger, + completionHandler: @escaping (NSFileProviderItem?, NSFileProviderItemFields, Bool, Error?) -> Void) -> Progress { + let parentPath = parentIdentifier.path + let fileName = itemTemplate.filename + let itemPath = (parentIdentifier.path as NSString).appendingPathComponent(fileName) + log.info("Creating file \(itemPath)") + + //let newItemIdentifier = NSFileProviderItemIdentifier(fileName, in: itemTemplate.parentItemIdentifier) + + let progress = Progress(totalUnitCount: 10) + + var createFileCancellable: AnyCancellable? = self._itemTranslator(for: parentIdentifier) + .flatMap { t in t.create(name: fileName, mode: S_IRWXU).flatMap { $0.close() }.map { _ in t } } + .flatMap { $0.cloneWalkTo(fileName) } + .flatMap { fileTranslator in + log.debug("Writing attributes") + var newAttributes: BlinkFiles.FileAttributes = [:] + if fields.contains(.creationDate) { + newAttributes[.creationDate] = itemTemplate.creationDate! + } + if fields.contains(.contentModificationDate) { + newAttributes[.modificationDate] = itemTemplate.contentModificationDate! + } + + return self.workingSet.commitItemInSet(itemPath: itemPath) { + fileTranslator.wstat(newAttributes).map { _ in fileTranslator } + .flatMap { + log.debug("Fetching \(fileName) attributes") + return $0.stat() + } + .map { + let newIdentifier = BlinkFileItemIdentifier.generate(name: fileName, parent: parentIdentifier) + let createdItem = FileProviderItem(blinkIdentifier: newIdentifier, attributes: $0) + return createdItem + } + .eraseToAnyPublisher() + } + } + .sink( + receiveCompletion: { completion in + switch completion { + case .finished: + log.info("Create File completed") + progress.completedUnitCount = progress.totalUnitCount + case .failure(let error): + log.error("Create file error: \(error)") + if let error = error as? NSFileProviderError { + completionHandler(nil, [], false, error) + } else { + completionHandler(nil, [], false, NSFileProviderError.operationError(dueTo: error)) + } + } + }, + receiveValue: { createdItem in + completionHandler(createdItem, + [], + false, + nil) + } + ) + + progress.cancellationHandler = { + log.warn("Create item cancelled by user") + createFileCancellable?.cancel() + createFileCancellable = nil + completionHandler(nil, [], false, NSError(domain: NSCocoaErrorDomain, code: NSUserCancelledError)) + } + + return progress + } + + func cleanUpOldTmpFiles() -> AnyCancellable { + // Enumerate on root, filter and remove. + let log = self.logger("cleanUpOldTmpFiles") + + return self.rootTranslator + .flatMap { translator in + translator.directoryFilesAndAttributes() + .flatMap { $0.publisher } + .filter { fileAttributes in + let fileName = fileAttributes[.name] as! String + guard let modificationDate = fileAttributes[.modificationDate] as? Date else { return false } + + return fileName.starts(with: ".blink.tmp.") && + modificationDate < Date().addingTimeInterval(-3600) + } + // Flow control. Do not overwhelm the connection. + .flatMap(maxPublishers: .max(3)) { fileAttributes in + let fileName = fileAttributes[.name] as! String + return translator.cloneWalkTo(fileName) + .flatMap { $0.remove() } + // Ignore errors. + .catch { _ in Just(false) } + .map { _ in fileName } + } + } + .sink( + receiveCompletion: { completion in + switch completion { + case .finished: + log.info("Completed") + case .failure(let error): + log.error("Clean up Tmp files error: \(error)") + } + }, + receiveValue: { fileName in + log.info("Cleaned up \(fileName)") + } + ) + } +} + +extension NSFileProviderItemFields: CustomDebugStringConvertible { + public var debugDescription: String { + var descriptions: [String] = [] + + if self.contains(.contents) { descriptions.append(".contents") } + if self.contains(.filename) { descriptions.append(".filename") } + if self.contains(.parentItemIdentifier) { descriptions.append(".parentItemIdentifier") } + if self.contains(.typeAndCreator) { descriptions.append(".typeAndCreator") } + if self.contains(.creationDate) { descriptions.append(".creationDate") } + if self.contains(.contentModificationDate) { descriptions.append(".contentModificationDate") } + if self.contains(.lastUsedDate) { descriptions.append(".lastUsedDate") } + if self.contains(.tagData) { descriptions.append(".tagData") } + if self.contains(.favoriteRank) { descriptions.append(".favoriteRank") } + + return "NSFileProviderItemFields: [" + descriptions.joined(separator: ", ") + "]" + } +} + +extension NSFileProviderCreateItemOptions: CustomDebugStringConvertible { + public var debugDescription: String { + var options = [String]() + + if contains(.mayAlreadyExist) { options.append("mayAlreadyExist") } + if contains(.deletionConflicted) { options.append("deletionConflicted") } + + return "NSFileProviderCreateItemOptions: [" + options.joined(separator: ", ") + "]" + } +} + +extension NSFileProviderModifyItemOptions: CustomDebugStringConvertible { + public var debugDescription: String { + var options = [String]() + + if contains(.mayAlreadyExist) { options.append("mayAlreadyExist") } + + return "NSFileProviderModifyItemOptions: [" + options.joined(separator: ", ") + "]" + } +} diff --git a/BlinkFileProvider/FileProviderReplicatedExtension.swift b/BlinkFileProvider/FileProviderReplicatedExtension.swift new file mode 100644 index 000000000..f59ef9407 --- /dev/null +++ b/BlinkFileProvider/FileProviderReplicatedExtension.swift @@ -0,0 +1,580 @@ +////////////////////////////////////////////////////////////////////////////////// +// +// B L I N K +// +// Copyright (C) 2016-2024 Blink Mobile Shell Project +// +// This file is part of Blink. +// +// Blink is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Blink is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details.no +// +// You should have received a copy of the GNU General Public License +// along with Blink. If not, see . +// +// In addition, Blink is also subject to certain additional terms under +// GNU GPL version 3 section 7. +// +// You should have received a copy of these additional terms immediately +// following the terms and conditions of the GNU General Public License +// which accompanied the Blink Source Code. If not, see +// . +// +//////////////////////////////////////////////////////////////////////////////// + +import BlinkFiles +import BlinkConfig +import Combine +import FileProvider + + +public class FileProviderReplicatedExtension: NSObject, NSFileProviderReplicatedExtension { + internal let connection: FilesTranslatorConnection + internal var cancellables: Set = [] + internal let copyArguments = CopyArguments(inplace: true, + preserve: [.permissions, .timestamp], + checkTimes: true) + internal let temporaryDirectoryURL: URL + internal let workingSet: WorkingSet + internal var rootTranslator: TranslatorPublisher { connection.rootTranslator } + + private let _logger: ((String) -> BlinkLogger) + + public required init(domain: NSFileProviderDomain) { + guard let fpm = NSFileProviderManager(for: domain), + let domainReference = domain.reference, + let domainProviderPath = domain.providerPath else { + fatalError("Could not initialize domain. Missing parameters.") + } + + guard let fileProviderURL = BlinkPaths.fileProviderReplicatedURL() else { + fatalError("Invalid shared FileProvider location for WorkingSets.") + } + + do { + let providerPath = try BlinkFileProviderPath(domainProviderPath) + self.connection = FilesTranslatorConnection(providerPath: providerPath, configurator: BlinkConfigFactoryConfiguration()) + } catch { + fatalError("Could not initialize domain: \(error)") + } + + do { + let loggingHandlers = try BlinkLoggingHandlers.fileProviderLoggingHandlers(domainName: "\(domain.displayName)-\(domain.identifier.rawValue.prefix(8))") + self._logger = { BlinkLogger($0, handlers: loggingHandlers) } + } catch { + fatalError("Could not initialize logging: \(error)") + } + + do { + temporaryDirectoryURL = try fpm.temporaryDirectoryURL() + } catch { + fatalError("failed to get temporary directory: \(error)") + } + + do { + let db = try WorkingSetDatabase(path: fileProviderURL.appendingPathComponent("\(domainReference).db").path(), reset: false) + let workingSetLogger = self._logger("WorkingSet") + self.workingSet = try WorkingSet(domain: domain, db: db, logger: workingSetLogger) + } catch { + fatalError("could not initialize working set database: \(error)") + } + + super.init() + + let log = logger("FP") + log.info("Started") + + DispatchQueue.global(qos: .background).asyncAfter(deadline: .now() + 5) { + self.workingSet.resumeChangesTimerEvery(seconds: 5) + } + + DispatchQueue.global(qos: .background).async { + // Background clean-up + self.cancellables.insert(self.cleanUpOldTmpFiles()) + } + } + + init(connection: FilesTranslatorConnection, workingSet: WorkingSet, temporaryDirectoryURL: URL) { + self.connection = connection + self.workingSet = workingSet + self.temporaryDirectoryURL = temporaryDirectoryURL + + self._logger = { BlinkLogger($0, handlers: [BlinkLoggingHandlers.print]) } + + super.init() + } + + func logger(_ component: String) -> BlinkLogger { + // Each Extension has its own Handler so we can log outputs to different files, etc... + // Loggers here are connected to each Handler per provider. + return self._logger(component) + } + + public func invalidate() { + // Cleanup any resources + let log = logger("Invalidate") + log.info("FP Extension") + self.workingSet.invalidate() + } + + public func item(for identifier: NSFileProviderItemIdentifier, request: NSFileProviderRequest, completionHandler: @escaping (NSFileProviderItem?, Error?) -> Void) -> Progress { + let log = self.logger("itemFor \(identifier.rawValue)") + log.info("Requested") + + // resolve the given identifier to a record in the model + if identifier == .trashContainer { + log.warn("Trash disabled") + completionHandler(nil, NSError(domain: NSCocoaErrorDomain, code: NSFeatureUnsupportedError, userInfo:[:])) + return Progress() + } + + var blinkIdentifier: BlinkFileItemIdentifier = .rootContainer + if identifier != .rootContainer { + do { + if let value = try self.workingSet.blinkIdentifier(for: identifier) { + blinkIdentifier = value + } else { + log.warn("Could not find blinkIdentifier in DB.") + completionHandler(nil, NSError.fileProviderErrorForNonExistentItem(withIdentifier: identifier)) + return Progress() + } + } catch { + log.debug("Error: \(error)") + completionHandler(nil, error) + return Progress() + } + } + + let progress = Progress(totalUnitCount: 1) + + let statItemProgress = self._statItem(blinkIdentifier, log: log, completionHandler: completionHandler) + progress.addChild(statItemProgress, withPendingUnitCount: 1) + + return progress + } + + public func fetchContents(for itemIdentifier: NSFileProviderItemIdentifier, version requestedVersion: NSFileProviderItemVersion?, request: NSFileProviderRequest, completionHandler: @escaping (URL?, NSFileProviderItem?, Error?) -> Void) -> Progress { + // Fetching of the contents for the itemIdentifier at the specified version + let log = self.logger("fetchContentsFor \(itemIdentifier.rawValue)") + log.info("started") + + let totalProgress = Progress(totalUnitCount: 110) + let itemForProgress = self.item(for: itemIdentifier, request: request) { (fileItem: NSFileProviderItem?, error: (any Error)?) in + guard let fileItem = fileItem as? FileProviderItem else { + let error = error! + log.error("Stat item error: \(error)") + completionHandler(nil, nil, error) + return + } + +// // Doesn't exist on iOS yet. +// if let requestedVersion = requestedVersion { +// guard requestedVersion.contentVersion == fileItem.itemVersion?.contentVersion else { +// let error = NSFileProviderError(.versionNoLongerAvailable) +// log.error("\(error)") +// completionHandler(nil, nil, error) +// return +// } + + let copyProgress = self._downloadItem(fileItem: fileItem, + log: log, + completionHandler: completionHandler) + + totalProgress.addChild(copyProgress, withPendingUnitCount: 100) + } + + totalProgress.addChild(itemForProgress, withPendingUnitCount: 10) + + return totalProgress + } + + public func createItem(basedOn itemTemplate: NSFileProviderItem, + fields: NSFileProviderItemFields, + contents url: URL?, + options: NSFileProviderCreateItemOptions = [], + request: NSFileProviderRequest, + completionHandler: @escaping (NSFileProviderItem?, NSFileProviderItemFields, Bool, Error?) -> Void) -> Progress { + // A new item was created on disk, process the item's creation + let log = self.logger("createItem \(itemTemplate.filename)") + log.info("Requested with \(fields.debugDescription) and \(options.debugDescription)") + let parentItemIdentifier = itemTemplate.parentItemIdentifier + var parentIdentifier: BlinkFileItemIdentifier + do { + guard let validParentIdentifier = try self.workingSet.blinkIdentifier(for: parentItemIdentifier) else { + completionHandler(nil, [], false, NSError.fileProviderErrorForNonExistentItem(withIdentifier: parentItemIdentifier)) + return Progress() + } + parentIdentifier = validParentIdentifier + } catch { + log.error("\(error)") + completionHandler(nil, [], false, error) + return Progress() + } + + // Template - itemIdentifier should stay stable between retries (but, I have seen retries use a different identifier). + // Set properties from itemTemplate into the object. Fields may have what has changed. + // Document in URL, otherwise nil. + // Not sure how to work with symlinks, because the destination may be somewhere else not part of WorkingSet. + // - The item needs to exist because otherwise it wouldn't be created pointing to a place within the structure? + // - Validate the destination beforehand? Support symlink reads before writes. + let totalProgress = Progress(totalUnitCount: 100) + + switch itemTemplate.contentType { + case .folder?: + log.info("Is a Folder") + let folderProgress = _createFolder(withName: itemTemplate.filename, + inParent: parentIdentifier, + log: log, + completionHandler: completionHandler) + totalProgress.addChild(folderProgress, withPendingUnitCount: totalProgress.totalUnitCount) + case .aliasFile?, .symbolicLink?: + log.warn("Is alias. Skipping") + completionHandler(itemTemplate, [], false, NSError(domain: NSCocoaErrorDomain, code: NSFeatureUnsupportedError, userInfo:[:])) + default: + log.info("Is a File") + // Upload on content + if fields.contains(.contents), + let url = url { + let createProgress = _createItem(basedOn: itemTemplate, + inParent: parentIdentifier, + fields: fields, + contents: url, + options: options, + request: request, + log: log, + completionHandler: completionHandler) + totalProgress.addChild(createProgress, withPendingUnitCount: totalProgress.totalUnitCount) + } else if options.contains(.mayAlreadyExist) { + // The system is calling with an already-existing item that's dataless. + // This only happens during a reimport, for items that the system hasn’t + // materialized before. In this case, return nil (which causes the + // system to delete the local item). After the system reenumerates the folder, it then recreates the file. + log.warn("Create unmaterialized empty file. Skipping.") + completionHandler(nil, [], false, nil) + } else { + log.info("Empty file") + let createFileProgress = _createEmptyFile(basedOn: itemTemplate, + inParent: parentIdentifier, + fields: fields, + options: options, + log: log, + completionHandler: completionHandler) + totalProgress.addChild(createFileProgress, withPendingUnitCount: totalProgress.totalUnitCount) + } + } + + return totalProgress + } + + public func modifyItem(_ item: NSFileProviderItem, + baseVersion version: NSFileProviderItemVersion, + changedFields: NSFileProviderItemFields, + contents newContents: URL?, + options: NSFileProviderModifyItemOptions = [], + request: NSFileProviderRequest, + completionHandler: @escaping (NSFileProviderItem?, NSFileProviderItemFields, Bool, Error?) -> Void) -> Progress { + let log = self.logger("modifyItem \(item.filename)") + log.info("Requested with \(changedFields.debugDescription) and \(options.debugDescription)") + + var originalIdentifier: BlinkFileItemIdentifier + // NOTE The parent may be in a different location than the current item if it is also reparenting. + var modifiedItemParent: BlinkFileItemIdentifier + do { + guard let validOriginalIdentifier = try self.workingSet.blinkIdentifier(for: item.itemIdentifier) else { + // TODO This may need a full resync instead as the identifier is not valid. + completionHandler(nil, [], false, NSError.fileProviderErrorForNonExistentItem(withIdentifier: item.itemIdentifier)) + return Progress() + } + originalIdentifier = validOriginalIdentifier + + guard let validModifiedItemParent = try self.workingSet.blinkIdentifier(for: item.parentItemIdentifier) else { + completionHandler(nil, [], false, NSError.fileProviderErrorForNonExistentItem(withIdentifier: item.parentItemIdentifier)) + return Progress() + } + modifiedItemParent = validModifiedItemParent + } catch { + log.error("\(error)") + completionHandler(nil, [], false, error) + return Progress() + } + + // Moving, renaming or updating content. + // Moving, renaming, attributes updated - stat + // Updating - reupload. + // Update entry on WorkingSet for all cases. + // You can only call completionHandler once, but I am unsure of what other changes we may have. + let totalProgress = Progress(totalUnitCount: 100) + + if changedFields.contains(.contents) { + if item.contentType == .symbolicLink { + log.info("Symlink") + completionHandler(nil, [], false, NSError(domain: NSCocoaErrorDomain, code: NSFeatureUnsupportedError, userInfo:[:])) + } else if let url = newContents { + log.info("File Content") + let uploadProgress = _uploadItem(item, + inParent: modifiedItemParent, + originalIdentifier: originalIdentifier, + baseVersion: version, + changedFields: changedFields, + contents: url, + options: options, + request: request, + log: log, + completionHandler: completionHandler) + totalProgress.addChild(uploadProgress, withPendingUnitCount: 100) + } else { + completionHandler(nil, changedFields, false, NSError(domain: NSCocoaErrorDomain, code: NSFeatureUnsupportedError, userInfo:[:])) + } + } else { + log.info("File Attributes") + // Cannot modify Symlink attributes + if originalIdentifier.itemIdentifier.isSymbolicLink() { + completionHandler(nil, changedFields, false, NSError(domain: NSCocoaErrorDomain, code: NSFeatureUnsupportedError, userInfo:[:])) + } else { + let modifyProgress = _modifyItemAttributes(originalIdentifier: originalIdentifier, + baseVersion: version, + name: changedFields.contains(.filename) ? item.filename : nil, + parent: changedFields.contains(.parentItemIdentifier) ? modifiedItemParent : nil, + creationDate: changedFields.contains(.creationDate) ? item.creationDate! : nil, + modificationDate: changedFields.contains(.contentModificationDate) ? item.contentModificationDate! : nil, + log: log, + completionHandler: completionHandler + ) + totalProgress.addChild(modifyProgress, withPendingUnitCount: 100) + + } + } + + return totalProgress + } + + public func deleteItem(identifier: NSFileProviderItemIdentifier, + baseVersion version: NSFileProviderItemVersion, + options: NSFileProviderDeleteItemOptions = [], + request: NSFileProviderRequest, + completionHandler: @escaping (Error?) -> Void) -> Progress { + // An item was deleted on disk, process the item's deletion. + // (Should read more like "request to delete an item on disk"). + let log = self.logger("deleteItem \(identifier.rawValue)") + + var blinkIdentifier: BlinkFileItemIdentifier + do { + guard let validBlinkIdentifier = try self.workingSet.blinkIdentifier(for: identifier) else { + completionHandler(NSError.fileProviderErrorForNonExistentItem(withIdentifier: identifier)) + return Progress() + } + blinkIdentifier = validBlinkIdentifier + log.info("At path \(blinkIdentifier.path)") + } catch { + log.error("\(error)") + completionHandler(error) + return Progress() + } + + // If file already deleted. + // Recursive deletion. + // Version - not relevant on iOS + // Update the WorkingSet accordingly. Even recursive. + // In this case, signal a refresh of the WorkingSet. It makes sense, because other items may be affected + // by that, and although in our case we keep a local state, others may not. + + let progress = Progress(totalUnitCount: 10) + let recursive = options.contains(.recursive) + // Single item deletions may come as recursive too +// if recursive { +// log.error("Recursive delete not supported") +// completionHandler(NSError(domain: NSCocoaErrorDomain, code: NSFeatureUnsupportedError, userInfo:[:])) +// return Progress() +// } + + // If we move this to BlinkFiles, we may want to report (maybe FileAttributes and current path) instead of just returning Void. + func delete(_ translators: [Translator]) -> AnyPublisher { + translators.publisher + .flatMap(maxPublishers: .max(1)) { t -> AnyPublisher in + log.debug(t.current) + if t.fileType == .typeDirectory { + return [deleteDirectoryContent(t), AnyPublisher(t.rmdir().map {_ in})] + .compactMap { $0 } + .publisher + .flatMap(maxPublishers: .max(1)) { $0 } + .collect() + .map {_ in} + .eraseToAnyPublisher() + } + + return AnyPublisher(t.remove().map { _ in }) + }.eraseToAnyPublisher() + } + + func deleteDirectoryContent(_ t: Translator) -> AnyPublisher? { + if recursive == false { + return nil + } + + return t.directoryFilesAndAttributes().flatMap { + $0.compactMap { i -> FileAttributes? in + if (i[.name] as! String) == "." || (i[.name] as! String) == ".." { + return nil + } else { + return i + } + }.publisher + } + .flatMap { i in + log.debug("processing: \((t.current as NSString).appendingPathComponent(i[.name] as! String))") + let fileType = i[.type] as? FileAttributeType + if fileType == .typeSymbolicLink { + return Just(t.clone()).tryMap { try $0.join(i[.name] as! String) } + .mapError { error in + log.error("Error at \((t.current as NSString).appendingPathComponent(i[.name] as! String))") + return error + }.eraseToAnyPublisher() + } else { + return t.cloneWalkTo(i[.name] as! String).mapError { error in + log.error("Error at \((t.current as NSString).appendingPathComponent(i[.name] as! String))") + return error + }.eraseToAnyPublisher() + } + } + .collect() + .flatMap { + delete($0) } + .eraseToAnyPublisher() + } + + var deleteCancellable: AnyCancellable? = nil + + deleteCancellable = self.rootTranslator + .flatMap { t -> AnyPublisher<[Translator], Never> in + if blinkIdentifier.itemIdentifier.isSymbolicLink() { + return Just(t.clone()) + .tryMap { try $0.join(blinkIdentifier.path) } + .collect() + .catch { error -> AnyPublisher<[Translator], Never> in + log.warn("Cannot resolve item. Skipping deletion. \(error)") + return Just([]).eraseToAnyPublisher() + } + .eraseToAnyPublisher() + } else { + return t.cloneWalkTo(blinkIdentifier.path) + .collect() + // If the walk fails (file does not exist), then finish and report. + .catch { error -> AnyPublisher<[Translator], Never> in + log.warn("Cannot walk to item. Skipping deletion. \(error)") + return Just([]).eraseToAnyPublisher() + } + .eraseToAnyPublisher() + } + } + .flatMap { delete($0) } + .sink( + receiveCompletion: { completion in + switch completion { + case .finished: + log.info("Completed") + progress.completedUnitCount = progress.totalUnitCount + completionHandler(nil) + case .failure(let error): + log.error("Error: \(error)") + completionHandler(error) + } + }, receiveValue: { _ in } + ) + + progress.cancellationHandler = { + deleteCancellable?.cancel() + deleteCancellable = nil + // Enumerate here as well for partial deletion + completionHandler(NSError(domain: NSCocoaErrorDomain, code: NSUserCancelledError)) + } + + return progress + } + + public func enumerator(for containerItemIdentifier: NSFileProviderItemIdentifier, request: NSFileProviderRequest) throws -> NSFileProviderEnumerator { + let log = self.logger("enumeratorFor \(containerItemIdentifier.rawValue)") + + if containerItemIdentifier == .workingSet { + log.info("Requested") + return WorkingSetEnumerator(workingSet: workingSet, logger: self.logger("enumeratorFor WorkingSet")) + } + + if containerItemIdentifier == .trashContainer { + throw NSError(domain: NSCocoaErrorDomain, code: NSFeatureUnsupportedError, userInfo:[:]) + } + + let blinkIdentifier = try containerItemIdentifier == .rootContainer ? + BlinkFileItemIdentifier.rootContainer : + { + guard let identifier = try workingSet.blinkIdentifier(for: containerItemIdentifier) else { + // TODO This may need a full resync instead as the identifier is not valid. + throw NSError.fileProviderErrorForNonExistentItem(withIdentifier: containerItemIdentifier) + } + return identifier + }() + + log.info("\(blinkIdentifier.description)") + let enumeratorLog = self.logger("enumeratorFor \(blinkIdentifier.itemIdentifier.rawValue) \(blinkIdentifier.description)") + let enumerator = FileProviderReplicatedEnumerator(for: blinkIdentifier, + workingSet: self.workingSet, + connection: self.connection, + logger: enumeratorLog) + try enumerator.makeActiveEnumerator() + return enumerator + } + + deinit { + let log = self.logger("deinit") + log.info("FP Extension") + } +} + +extension NSFileProviderDomain { + private var _components: [String]? { + let components = self.identifier.rawValue.components(separatedBy: "-") + guard components.count == 2 else { + return nil + } + return components + } + + var reference: String? { _components?[0] } + var providerPath: String? { + guard let components = _components, + let data = Data(base64Encoded: components[1]) else { + return nil + } + + return String(data: data, encoding: .utf8) + } +} + +fileprivate extension BlinkLoggingHandlers { + static func fileProviderLoggingHandlers(domainName: String) throws -> [BlinkLogging.LogHandlerFactory] { + let fileLoggingURL = BlinkPaths.blinkURL().appendingPathComponent("fp-\(domainName).log") + let fileLogging = try FileLogging(to: fileLoggingURL) + + let printHandler = Self.print + let outputHandler: BlinkLogging.LogHandlerFactory = + { + try $0.filter(logLevel: .debug) + // Format + .format { [ + "[\(Date().formatted(.iso8601))]", + "[\($0[.logLevel] ?? BlinkLogLevel.log)]", + $0[.component] as? String ?? "global", + $0[.message] as? String ?? "" + ].joined(separator: " : ") } + .sinkToFile(fileLogging) + } + + return [printHandler, outputHandler] + } +} diff --git a/BlinkFileProvider/FilesTranslatorConnection.swift b/BlinkFileProvider/FilesTranslatorConnection.swift new file mode 100644 index 000000000..fd8665408 --- /dev/null +++ b/BlinkFileProvider/FilesTranslatorConnection.swift @@ -0,0 +1,237 @@ +////////////////////////////////////////////////////////////////////////////////// +// +// B L I N K +// +// Copyright (C) 2016-2019 Blink Mobile Shell Project +// +// This file is part of Blink. +// +// Blink is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Blink is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Blink. If not, see . +// +// In addition, Blink is also subject to certain additional terms under +// GNU GPL version 3 section 7. +// +// You should have received a copy of these additional terms immediately +// following the terms and conditions of the GNU General Public License +// which accompanied the Blink Source Code. If not, see +// . +// +//////////////////////////////////////////////////////////////////////////////// + +import BlinkFiles +import FileProvider +import Combine + +import SSH + + +public typealias TranslatorPublisher = AnyPublisher + +public class FilesTranslatorConnection { + private let providerPath: BlinkFileProviderPath + private let configurator: any FileTranslatorFactory.Configurator + private var _rootTranslator: Translator? = nil + private var _rootTranslatorPublisher: TranslatorPublisher? = nil + private let _rootTranslatorQueue = DispatchQueue(label: "sh.blink.FileProvider.FileTranslatorConnection.rootTranslatorQueue") + + var rootTranslatorPath: String! + + public var rootTranslator: TranslatorPublisher { + return self._rootTranslatorQueue.sync { + if let rootTranslatorPublisher = _rootTranslatorPublisher, + // If we are connecting + _rootTranslator == nil { + print("Send current translator") + return rootTranslatorPublisher + } else if let rootTranslatorPublisher = _rootTranslatorPublisher, + // If we are still connected + let rootTranslator = _rootTranslator, + rootTranslator.isConnected { + print("Send current translator") + return rootTranslatorPublisher + } + + print("New translator") + self._rootTranslator = nil + self._rootTranslatorPublisher = nil + + let rootTranslatorPublisher = rootTranslatorPublisher() + self._rootTranslatorPublisher = rootTranslatorPublisher + return rootTranslatorPublisher + } + } + + public init(providerPath: BlinkFileProviderPath, configurator: any FileTranslatorFactory.Configurator) { + self.providerPath = providerPath + self.configurator = configurator + } + + private func rootTranslatorPublisher() -> TranslatorPublisher { + FileTranslatorFactory.rootTranslator(for: providerPath, configurator: configurator) + .map { [weak self] t in + self?._rootTranslatorQueue.async(flags: .barrier) { [weak self] in + guard let self = self else { return } + self._rootTranslator = t + self.rootTranslatorPath = t.current + } + return t + } + // It can happen that a FTC gets cancelled before the elements are passed down. + // We need to subsequently nil the publisher as otherwise the previous map won't reset. + .handleEvents( + receiveCompletion: { [weak self] completion in + guard let self = self else { return } + self._rootTranslatorQueue.async(flags: .barrier) { [weak self] in + guard let self = self else { return } + if case let .failure(error) = completion { + print("Connection error - \(error)") + self._rootTranslatorPublisher = nil + self._rootTranslator = nil + } + } + }, + receiveCancel: { [weak self] in + guard let self = self else { return } + self._rootTranslatorQueue.async(flags: .barrier) { [weak self] in + guard let self = self else { return } + print("TranslatorPublisher cancelled") + self._rootTranslator = nil + self._rootTranslatorPublisher = nil + } + }) + .shareReplay(maxValues: 1) + .eraseToAnyPublisher() + } +} + +enum BlinkFilesProtocol: String { + case local = "local" + case sftp = "sftp" +} + +public struct BlinkFileProviderPath { + private let fullPath: String + + var proto: BlinkFilesProtocol + var hostPath: String? // user@host#port + var filePath: String + + public init(_ fullPath: String) throws { + self.fullPath = fullPath + + let components = self.fullPath.components(separatedBy: ":") + + switch components.count { + case 1: + // For a local path, we already have either absolute or relative to current + let filePath = components[0] + if filePath.starts(with: "/") { + self.filePath = filePath + } else if filePath.starts(with: "~") { + self.filePath = ("/" as NSString).appendingPathComponent(filePath) + } else { + self.filePath = (FileManager.default.currentDirectoryPath as NSString) + .appendingPathComponent(filePath) + } + + self.proto = .local + case 2: + // For remote paths, we start with absolute / or relative to ~ + let filePath = components[1] + if filePath.isEmpty { + self.filePath = "/~" + } else if filePath.starts(with: "/") { + self.filePath = filePath + } else if filePath.starts(with: "~") { + self.filePath = "/\(filePath)" + } else { // Relative + self.filePath = "/~/\(filePath)" + } + + var host = components[0] + if host.starts(with: "/") { + host.removeFirst() + } + self.hostPath = host + self.proto = .sftp + default: + let filePath = components[2...].joined(separator: ":") + if filePath.isEmpty { + self.filePath = "/~" + } else if filePath.starts(with: "/") { + self.filePath = filePath + } else if filePath.starts(with: "~") { + self.filePath = "/\(filePath)" + } else { // Relative + self.filePath = "/~/\(filePath)" + } + + self.hostPath = components[1] + var protoString = components[0] + if protoString.starts(with: "/") { + protoString.removeFirst() + } + + guard let proto = BlinkFilesProtocol(rawValue: protoString) else { + throw NSFileProviderError.noDomainProvided + } + self.proto = proto + } + } +} + +public enum FileTranslatorFactory { + // The configurator gives us more flexibility on locations for configurations. So BlinkTests or others do not depend on + // Blink locations. + public protocol Configurator { + func sshConfig(host title: String) throws -> (String, SSHClientConfig) + } + + static func rootTranslator(for path: BlinkFileProviderPath, configurator: Configurator) -> AnyPublisher { + // TODO The domain.pathRelativeToDocumentStorage shouldn't be used in new Replicated Extension. + // TODO This should probably receive a string, or another object that simplifies the setup, instead of a Domain, which is an + // object from another domain. + + switch path.proto { + case .local: + return Local().walkTo(path.filePath) + case .sftp: + guard let host = path.hostPath else { + return .fail(error: NSFileProviderError(errorCode: 400, errorDescription: "Missing host in Translator route")) + } + + let dial = SSHClient.dialInThread(host, withConfigProvider: configurator.sshConfig) + .print("dialInThread") + .flatMap { conn -> AnyPublisher in + //conn.handleSessionException = { error in print("SFTP Connection Exception \(error)") } + return conn.requestSFTP() + } + .tryMap { try SFTPTranslator(on: $0) } + .flatMap { $0.walkTo(path.filePath) } + .shareReplay(maxValues: 1) + .eraseToAnyPublisher() + + return dial + .eraseToAnyPublisher() + } + } +} + +public class BlinkConfigFactoryConfiguration : FileTranslatorFactory.Configurator { + public init() {} + + public func sshConfig(host title: String) throws -> (String, SSH.SSHClientConfig) { + try SSHClientConfigProvider.config(host: title) + } +} diff --git a/BlinkFileProvider/Models/BlinkItemIdentifier.swift b/BlinkFileProvider/Models/BlinkItemIdentifier.swift deleted file mode 100644 index 303760dcd..000000000 --- a/BlinkFileProvider/Models/BlinkItemIdentifier.swift +++ /dev/null @@ -1,98 +0,0 @@ -////////////////////////////////////////////////////////////////////////////////// -// -// B L I N K -// -// Copyright (C) 2016-2019 Blink Mobile Shell Project -// -// This file is part of Blink. -// -// Blink is free software: you can redistribute it and/or modify -// it under the terms of the GNU General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// Blink is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with Blink. If not, see . -// -// In addition, Blink is also subject to certain additional terms under -// GNU GPL version 3 section 7. -// -// You should have received a copy of these additional terms immediately -// following the terms and conditions of the GNU General Public License -// which accompanied the Blink Source Code. If not, see -// . -// -//////////////////////////////////////////////////////////////////////////////// - -import FileProvider -import Foundation - - -struct BlinkItemIdentifier { - let path: String - let encodedRootPath: String - - var rootPath: String? { - guard let rootData = Data(base64Encoded: encodedRootPath), - let rootPath = String(data: rootData, encoding: .utf8) else { - return nil - } - return rootPath - } - // /path/to, name = filename. -> /path/to/filename - init(parentItemIdentifier: BlinkItemIdentifier, filename: String) { - self.encodedRootPath = parentItemIdentifier.encodedRootPath - self.path = (parentItemIdentifier.path as NSString).appendingPathComponent(filename) - } - - // /path/to/filename - init(_ identifier: NSFileProviderItemIdentifier) { - let parts = identifier.rawValue.split(separator: "/", maxSplits: 1, omittingEmptySubsequences: false).map(String.init) - self.encodedRootPath = parts[0] - if parts.count > 1 { - self.path = parts[1] - } else { - self.path = "" - } - } - - init(_ identifier: String) { - self.init(NSFileProviderItemIdentifier(identifier)) - } - - // file:////path/to/filename - var url: URL { - let manager = NSFileProviderManager.default - let pathcomponents = "\(encodedRootPath)/\(self.path)" - return manager.documentStorageURL.appendingPathComponent(pathcomponents) - } - - var filename: String { - return (path as NSString).lastPathComponent - } - - var itemIdentifier: NSFileProviderItemIdentifier { - if path.isEmpty { - return .rootContainer - } - return NSFileProviderItemIdentifier( - rawValue: "\(encodedRootPath)/\(path)" - ) - } - - var parentIdentifier: NSFileProviderItemIdentifier { - let parentPath = (path as NSString).deletingLastPathComponent - if parentPath == "/" || parentPath.isEmpty { - return .rootContainer - } else { - return NSFileProviderItemIdentifier( - rawValue: "\(encodedRootPath)/\(parentPath)" - ) - } - } -} diff --git a/BlinkFileProvider/Models/BlinkItemReference.swift b/BlinkFileProvider/Models/BlinkItemReference.swift deleted file mode 100644 index e096087dd..000000000 --- a/BlinkFileProvider/Models/BlinkItemReference.swift +++ /dev/null @@ -1,293 +0,0 @@ -////////////////////////////////////////////////////////////////////////////////// -// -// B L I N K -// -// Copyright (C) 2016-2019 Blink Mobile Shell Project -// -// This file is part of Blink. -// -// Blink is free software: you can redistribute it and/or modify -// it under the terms of the GNU General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// Blink is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with Blink. If not, see . -// -// In addition, Blink is also subject to certain additional terms under -// GNU GPL version 3 section 7. -// -// You should have received a copy of these additional terms immediately -// following the terms and conditions of the GNU General Public License -// which accompanied the Blink Source Code. If not, see -// . -// -//////////////////////////////////////////////////////////////////////////////// - -import Combine -import Foundation -import FileProvider -import MobileCoreServices - -import BlinkFiles -import UniformTypeIdentifiers - - -// Goal is to bridge the Identifier to the underlying BlinkFiles system, and to offer -// Representations of the item. -final class BlinkItemReference: NSObject { - private let identifier: BlinkItemIdentifier - var remote: BlinkFiles.FileAttributes? - var local: BlinkFiles.FileAttributes? - var parentItem: BlinkItemReference? - - var primary: BlinkFiles.FileAttributes = [:] - var replica: BlinkFiles.FileAttributes? - - var isDownloaded: Bool = false - var downloadingTask: AnyCancellable? = nil - var downloadingError: Error? = nil - - var uploadingTask: AnyCancellable? = nil - var isUploaded: Bool = false - var uploadingError: Error? = nil - - var syncAnchor: UInt = 0 - - // MARK: - Enumerator Entry Point: - // Requires attributes. If you only have the Identifier, you need to go to the DB. - // Identifier format /path/to/more/components/filename - init(_ itemIdentifier: BlinkItemIdentifier, - remote: BlinkFiles.FileAttributes? = nil, - local: BlinkFiles.FileAttributes? = nil, - cache: FileTranslatorCache) { - self.remote = remote - self.identifier = itemIdentifier - self.local = local - - super.init() - - self.parentItem = cache.reference(identifier: BlinkItemIdentifier(self.parentItemIdentifier)) - - evaluate() - } - - func updateAttributes(remote: BlinkFiles.FileAttributes, local: BlinkFiles.FileAttributes? = nil) { - self.remote = remote - if let local = local { - self.local = local - } - evaluate() - updateSyncAnchor() - } - - // Sync anchor of the container is increased when an item inside it changes. - // The sync anchor for an item itself is the one that updated the parent. - private func updateSyncAnchor() { - self.parentItem?.syncAnchor += 1 - self.syncAnchor = self.parentItem?.syncAnchor ?? self.syncAnchor + 1 - } - - private func evaluate() { - guard let remoteModified = (remote?[.modificationDate] as? Date) else { - primary = local! - replica = nil - isDownloaded = false - return - } - - guard let localModified = (local?[.modificationDate] as? Date) else { - primary = remote! - replica = nil - isDownloaded = false - return - } - - // Floor modified times as on some platforms it is a dobule with decimals - let epochRemote = floor(remoteModified.timeIntervalSince1970) - let epochLocal = floor(localModified.timeIntervalSince1970) - if epochRemote > epochLocal { - primary = remote! - replica = local - isDownloaded = false - isUploaded = true - } else if epochRemote == epochLocal { - primary = local! - replica = remote - isDownloaded = true - isUploaded = true - } else { - // This is inconsistent (maybe an interrupted upload?), so we go with - // whatever the remote has. - primary = remote! - replica = primary - isDownloaded = false - isUploaded = false - } - } - - var path: String { - identifier.path - } - - var encodedRootPath: String { - identifier.encodedRootPath - } - - var url: URL { - identifier.url - } - - var isDirectory: Bool { - return (primary[.type] as? FileAttributeType) == .typeDirectory - } - - var filename: String { - if identifier.filename.isEmpty { - return "/" - } - return identifier.filename - } - - var permissions: PosixPermissions? { - guard let perm = primary[.posixPermissions] as? NSNumber else { - return nil - } - return PosixPermissions(rawValue: perm.int16Value) - } - - func downloadStarted(_ c: AnyCancellable) { - downloadingTask = c - downloadingError = nil - updateSyncAnchor() - evaluate() - } - - func downloadCompleted(_ error: Error?) { - if let error = error { - downloadingError = error - downloadingTask = nil - return - } - - local = remote - downloadingTask = nil - updateSyncAnchor() - evaluate() - } - - func uploadStarted(_ c: AnyCancellable) { - uploadingTask = c - uploadingError = nil - updateSyncAnchor() - } - - func uploadCompleted(_ error: Error?) { - if let error = error { - uploadingError = error - uploadingTask = nil - return - } - - remote = local - uploadingTask = nil - updateSyncAnchor() - evaluate() - } -} - -// MARK: - NSFileProviderItem - -extension BlinkItemReference: NSFileProviderItem { - var parentItemIdentifier: NSFileProviderItemIdentifier { identifier.parentIdentifier } - var childItemCount: NSNumber? { nil } - var creationDate: Date? { primary[.creationDate] as? Date } - var contentModificationDate: Date? { primary[.modificationDate] as? Date } - var documentSize: NSNumber? { primary[.size] as? NSNumber } - var itemIdentifier: NSFileProviderItemIdentifier { identifier.itemIdentifier } - var isDownloading: Bool { downloadingTask != nil } - // Indicates whether the item is the most recent version downloaded from the server. - // In our case, there is only one version, so if it is downloaded, it is the most recent - // TODO Not sure how this will play out when the local may be the most recent version. - var isMostRecentVersionDownloaded: Bool { isDownloaded } - var isTrashed: Bool { false } - var isUploading: Bool { uploadingTask != nil } - - var contentType: UTType { - guard let type = primary[.type] as? FileAttributeType else { - print("\(itemIdentifier) missing type") - return UTType.data - } - if type == .typeDirectory { - return UTType.directory - } - - let pathExtension = (filename as NSString).pathExtension - if let type = UTType(filenameExtension: pathExtension) { - return type - } else { - return UTType.item - } - - // Old API would assign dyn when converting to unknown types. - // https://stackoverflow.com/questions/43518514/why-is-uttypecreatepreferredidentifierfortag-returning-strange-uti - // It looks like this is not necessary anymore as we will receive always a valid type or we can return item directly. - // Leaving here for now as reference in case we cause a regression. - // if typeIdentifier.starts(with: "dyn") { - // return kUTTypeItem as String - // } - // - } - - var capabilities: NSFileProviderItemCapabilities { - guard let permissions = self.permissions else { - return [] - } - - var c = NSFileProviderItemCapabilities() - if isDirectory { - print("Capabilities for \(self.filename)") - c.formUnion(.allowsAddingSubItems) - if permissions.contains(.ux) { - c.formUnion([.allowsContentEnumerating, .allowsReading]) - } - if permissions.contains(.uw) { - c.formUnion([.allowsRenaming, .allowsDeleting]) - } - } else { - if permissions.contains(.ur) { - c.formUnion(.allowsReading) - } - if permissions.contains(.uw) { - c.formUnion([.allowsWriting, .allowsDeleting, .allowsRenaming, .allowsReparenting]) - } - } - - return c - } -} - -struct PosixPermissions: OptionSet { - let rawValue: Int16 // It is really a CShort - - // rwx - // u[ser] - static let ur = PosixPermissions(rawValue: 1 << 8) - static let uw = PosixPermissions(rawValue: 1 << 7) - static let ux = PosixPermissions(rawValue: 1 << 6) - - // g[roup] - static let gr = PosixPermissions(rawValue: 1 << 5) - static let gw = PosixPermissions(rawValue: 1 << 4) - static let gx = PosixPermissions(rawValue: 1 << 3) - - // o[ther] - static let or = PosixPermissions(rawValue: 1 << 2) - static let ow = PosixPermissions(rawValue: 1 << 1) - static let ox = PosixPermissions(rawValue: 1 << 0) -} diff --git a/BlinkFileProvider/Models/FileTranslatorCache.swift b/BlinkFileProvider/Models/FileTranslatorCache.swift deleted file mode 100644 index 4092bc14d..000000000 --- a/BlinkFileProvider/Models/FileTranslatorCache.swift +++ /dev/null @@ -1,226 +0,0 @@ -////////////////////////////////////////////////////////////////////////////////// -// -// B L I N K -// -// Copyright (C) 2016-2019 Blink Mobile Shell Project -// -// This file is part of Blink. -// -// Blink is free software: you can redistribute it and/or modify -// it under the terms of the GNU General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// Blink is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with Blink. If not, see . -// -// In addition, Blink is also subject to certain additional terms under -// GNU GPL version 3 section 7. -// -// You should have received a copy of these additional terms immediately -// following the terms and conditions of the GNU General Public License -// which accompanied the Blink Source Code. If not, see -// . -// -//////////////////////////////////////////////////////////////////////////////// - -import Combine -import FileProvider -import Foundation - -import BlinkConfig -import BlinkFiles -import SSH - - -class TranslatorControl { - let translator: Translator - let connectionControl: SSHClientControl - - init(_ translator: Translator, connectionControl: SSHClientControl) { - self.translator = translator - self.connectionControl = connectionControl - } - - deinit { - self.connectionControl.cancel() - } -} - -enum BlinkFilesProtocol: String { - case ssh = "ssh" - case local = "local" - case sftp = "sftp" -} - -var logCancellables = Set() - - -final class FileTranslatorCache { - //static let shared = FileTranslatorCache() - private var translators: [String: TranslatorControl] = [:] - private var references: [String: BlinkItemReference] = [:] - private var fileList = [String: [BlinkItemReference]]() - - init() {} - - func rootTranslator(for identifier: BlinkItemIdentifier) -> AnyPublisher { - let encodedRootPath = identifier.encodedRootPath - - // Check if we have it cached, if it is still working - if let translatorRef = self.translators[encodedRootPath], - translatorRef.translator.isConnected { - return .just(translatorRef.translator) - } - - guard let rootPath = identifier.rootPath else { - return Fail(error: "Wrong encoded identifier for Translator").eraseToAnyPublisher() - } - - // rootPath: ssh:host:root_folder - let components = rootPath.split(separator: ":") - guard let remoteProtocol = BlinkFilesProtocol(rawValue: String(components[0])) else { - return .fail(error: "Not implemented") - } - - let pathAtFiles: String - let host: String? - if remoteProtocol == .local { - pathAtFiles = String(rootPath[components[1].startIndex...]) - host = nil - } else { - // The path will take the rest, independent of the components, because the colon is a valid character (not POSIX though) - pathAtFiles = String(rootPath[components[2].startIndex...]) - host = String(components[1]) - } - - switch remoteProtocol { - case .local: - return Local().walkTo(pathAtFiles) - case .sftp: - print("Could not find translator for \(host) at \(rootPath)") - - guard let host = host else { - return .fail(error: "Missing host in Translator route") - } - return SSHClient.dial(host, withConfigProvider: SSHClientConfigProvider.config) - .flatMap { connControl in - return Just(connControl.connection) - .flatMap { conn -> AnyPublisher in - conn.handleSessionException = { error in print("SFTP Connection Exception \(error)") } - return conn.requestSFTP() - } - .tryMap { try SFTPTranslator(on: $0) } - .flatMap { $0.walkTo(pathAtFiles) } - .map { t -> Translator in - self.translators[encodedRootPath] = TranslatorControl(t, connectionControl: connControl) - return t - } - } - // On start, multiple subscribers may connect here, and they may end up overwriting their translators, - // closing their connections, etc... This ensures only one goes through. - .shareReplay(maxValues: 1) - .eraseToAnyPublisher() - default: - return .fail(error: "Not implemented") - } - } - - func store(reference: BlinkItemReference) { - print("storing File BlinkItemReference : \(reference.itemIdentifier.rawValue)") - self.references[reference.itemIdentifier.rawValue] = reference - if reference.itemIdentifier != .rootContainer { - if var list = self.fileList[reference.parentItemIdentifier.rawValue] { - list.append(reference) - self.fileList[reference.parentItemIdentifier.rawValue] = list - } else { - self.fileList[reference.parentItemIdentifier.rawValue] = [reference] - } - } - } - - func remove(reference: BlinkItemReference) { - self.references.removeValue(forKey: reference.itemIdentifier.rawValue) - } - - func reference(identifier: BlinkItemIdentifier) -> BlinkItemReference? { - print("requesting File BlinkItemReference : \(identifier.itemIdentifier.rawValue)") - return self.references[identifier.itemIdentifier.rawValue] - } - - func reference(url: URL) -> BlinkItemReference? { - // containerPath may not be the same when accessing for different app. It may have a /private prefix. - // To obtain the reference, we delete up to File Provider Storage. - // file:///File Provider Storage///filename - // file:///File Provider Storage//path/filename - let encodedPath = url.path - guard let range: Range = encodedPath.range(of: "File Provider Storage/") else { - return nil - } - - var cleanPath = encodedPath[range.upperBound...] - if cleanPath.hasPrefix("/") { - cleanPath.removeFirst() - } - - return self.references[String(cleanPath)] - } - - func updatedItems(container: BlinkItemIdentifier, since anchor: UInt) -> [BlinkItemReference]? { - self.fileList[container.itemIdentifier.rawValue]?.filter { - anchor < $0.syncAnchor - } - } -} - - -class SSHClientConfigProvider { - - static func config(host title: String) throws -> (String, SSHClientConfig) { - - // NOTE This is just regular config initialization. Usually happens on AppDelegate, but the - // FileProvider doesn't get another chance. - BKHosts.loadHosts() - BKPubKey.loadIDS() - - let bkConfig = try BKConfig() - let agent = SSHAgent() - let consts: [SSHAgentConstraint] = [SSHConstraintTrustedConnectionOnly()] - - let host = try bkConfig.bkSSHHost(title) - - if let signers = bkConfig.signer(forHost: host) { - signers.forEach { (signer, name) in - agent.loadKey(signer, aka: name, constraints: consts) - } - } else { - for (signer, name) in bkConfig.defaultSigners() { - agent.loadKey(signer, aka: name, constraints: consts) - } - } - - var availableAuthMethods: [AuthMethod] = [AuthAgent(agent)] - if let password = host.password, !password.isEmpty { - availableAuthMethods.append(AuthPassword(with: password)) - } - - let log = BlinkLogger("SSH") - let logger = PassthroughSubject() - logger.sink { - log.send($0) - - }.store(in: &logCancellables) - - - return (host.hostName ?? title, - host.sshClientConfig(authMethods: availableAuthMethods, - agent: agent, - logger: logger) - ) - } -} diff --git a/BlinkFileProvider/SSH/SSHClient.swift b/BlinkFileProvider/SSH/SSHClient.swift index 1448a5838..acff6b5a7 100644 --- a/BlinkFileProvider/SSH/SSHClient.swift +++ b/BlinkFileProvider/SSH/SSHClient.swift @@ -105,7 +105,7 @@ extension SSHClient { cancelProxy(nil) return } - + let destination = proxyCommand.stdioForward proxyCancellable = @@ -164,15 +164,122 @@ extension SSHClient { } } +extension SSHClient { + static func dialInThread(_ host: String, withConfigProvider configProvider: @escaping SSHClientConfigProviderMethod) -> AnyPublisher { + let hostName: String + let config: SSHClientConfig + do { + (hostName, config) = try configProvider(host) + } catch { + return .fail(error: error) + } + + var proxyCancellable: AnyCancellable? + var proxyStream: SSH.Stream? = nil + let execProxyCommand: SSHClient.ExecProxyCommandCallback = { (command, sockIn, sockOut) in + let output = DispatchOutputStream(stream: sockOut) + let input = DispatchInputStream(stream: sockIn) + + let cancelProxy = { (error: Error?) in + // This is necessary in order to propagate when the streams close. + shutdown(sockIn, SHUT_RDWR) + shutdown(sockOut, SHUT_RDWR) + + // Not necessary, but for cleanliness in order to track the actions when debugging. + // Otherwise, everything gets cleaned up once the whole session is detached. + proxyStream?.cancel() + proxyStream = nil + } + + guard let proxyCommand = try? ProxyCommand(command) else { + print("Could not parse Proxy Command") + cancelProxy(nil) + return + } + + let destination = proxyCommand.stdioForward + + proxyCancellable = + SSH.SSHClient.dialInThread(proxyCommand.hostAlias, withConfigProvider: configProvider) + .flatMap { conn -> AnyPublisher in + // proxyConnectionControl = connControl + conn.handleSessionException = { error in + cancelProxy(error) + } + return conn.requestForward(to: destination.bindAddress, + port: Int32(destination.port), + from: "blinkJumpHost", + localPort: 22) + } + .sink( + receiveCompletion: { completion in + switch completion { + case .finished: + break + case .failure(let error): + print(error) + } + // Self-retain until it is done. + proxyCancellable = nil + }, + receiveValue: { s in + proxyStream = s + s.connect(stdout: output, stdin: input) + // The proxyStream is self-retaining itself for cancellation. + s.handleFailure = { error in + cancelProxy(error) + } + s.handleCompletion = { cancelProxy(nil) } + } + ) + } + + let pb = PassthroughSubject() + var dial: AnyCancellable? + + let t = Thread { + let runLoop = RunLoop.current + + dial = SSH.SSHClient.dial(hostName, with: config, withProxy: execProxyCommand) + .print("SSHClient dialInThread") + .mapError { error in + if let sshError = error as? SSHError, case .authFailed = sshError { + return NSFileProviderError(.notAuthenticated, userInfo: [NSLocalizedFailureReasonErrorKey: sshError.description]) + } else { + return NSFileProviderError(.serverUnreachable, userInfo: [NSLocalizedFailureReasonErrorKey: error.localizedDescription]) + } + } + .sink( + receiveCompletion: { completion in + pb.send(completion: completion) + }, + receiveValue: { conn in + pb.send(conn) + }) + + SSH.SSHClient.run(withTimer: true) + print("SSHClient dialInThread Out") + } + + return Just(t) + .flatMap { t in + t.start() + return pb + }.buffer(size: 1, prefetch: .byRequest, whenFull: .dropOldest) + .eraseToAnyPublisher() + } +} + + fileprivate struct ProxyCommand { struct Error: Swift.Error { let description: String } - + let jumpHost: String? let stdioForward: BindAddressInfo let hostAlias: String - + // The command we receive is pre-fabricated by LibSSH, so we capture // looped JumpHosts, StdioForward and HostAlias in that order. // ssh -J l,l -W [127.0.0.1]:22 l @@ -188,19 +295,19 @@ fileprivate struct ProxyCommand { else { throw Error(description: "Invalid ProxyCommand \(command)") } - + if let r = Range(match.range(withName: "JumpHost"), in: command) { self.jumpHost = String(command[r]) } else { self.jumpHost = nil } - + if let r = Range(match.range(withName: "StdioForward"), in: command) { self.stdioForward = try BindAddressInfo(String(command[r])) } else { throw Error(description: "Missing forward. \(command)") } - + if let r = Range(match.range(withName: "HostAlias"), in: command) { self.hostAlias = String(command[r]) } else { @@ -208,3 +315,51 @@ fileprivate struct ProxyCommand { } } } + +var logCancellables = Set() + +enum SSHClientConfigProvider { + + static func config(host title: String) throws -> (String, SSHClientConfig) { + + // NOTE This is just regular config initialization. Usually happens on AppDelegate, but the + // FileProvider doesn't get another chance. + BKHosts.loadHosts() + BKPubKey.loadIDS() + + let bkConfig = try BKConfig() + let agent = SSHAgent() + let consts: [SSHAgentConstraint] = [SSHConstraintTrustedConnectionOnly()] + + let host = try bkConfig.bkSSHHost(title) + + if let signers = bkConfig.signer(forHost: host) { + signers.forEach { (signer, name) in + agent.loadKey(signer, aka: name, constraints: consts) + } + } else { + for (signer, name) in bkConfig.defaultSigners() { + agent.loadKey(signer, aka: name, constraints: consts) + } + } + + var availableAuthMethods: [AuthMethod] = [AuthAgent(agent)] + if let password = host.password, !password.isEmpty { + availableAuthMethods.append(AuthPassword(with: password)) + } + + let log = BlinkLogger("SSH") + let logger = PassthroughSubject() + logger.sink { + log.send($0) + + }.store(in: &logCancellables) + + + return (host.hostName ?? title, + host.sshClientConfig(authMethods: availableAuthMethods, + agent: agent, + logger: logger) + ) + } +} diff --git a/BlinkFileProvider/WorkingSetDatabase.swift b/BlinkFileProvider/WorkingSetDatabase.swift new file mode 100644 index 000000000..c90519f07 --- /dev/null +++ b/BlinkFileProvider/WorkingSetDatabase.swift @@ -0,0 +1,400 @@ +////////////////////////////////////////////////////////////////////////////////// +// +// B L I N K +// +// Copyright (C) 2016-2024 Blink Mobile Shell Project +// +// This file is part of Blink. +// +// Blink is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Blink is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Blink. If not, see . +// +// In addition, Blink is also subject to certain additional terms under +// GNU GPL version 3 section 7. +// +// You should have received a copy of these additional terms immediately +// following the terms and conditions of the GNU General Public License +// which accompanied the Blink Source Code. If not, see +// . +// +//////////////////////////////////////////////////////////////////////////////// + +import FileProvider +import Foundation +import SQLite +import struct SQLite.Expression + + +private let stateTable = Table("State") +private let itemKey = Expression("Item") +private let containerKey = Expression("Container") +private let versionKey = Expression("Version") +private let isContainerKey = Expression("isContainer") +private let anchorKey = Expression("Anchor") +private let nameKey = Expression("Name") +private let containerPathKey = Expression("ContainerPath") + +fileprivate extension Connection { + var userVersion: Int { + get { return Int(try! scalar("PRAGMA user_version") as! Int64) } + set { try! run("PRAGMA user_version = \(newValue)") } + } +} + +public class WorkingSetDatabase { + private let db: Connection + static let dbVersion = 10 + private let log: BlinkLogger + + private let stateTable = Table("State") + private let reparentedTable = Table("Reparented") + + var anchorVersion: String { + get { + return try! db.scalar("SELECT value FROM metadata WHERE key = 'anchor_version'") as? String ?? "" + } + set { + try! db.run("INSERT OR REPLACE INTO metadata (key, value) VALUES ('anchor_version', ?)", newValue) + } + } + + public init(path: String, reset: Bool = false) throws { + self.log = BlinkLogger("DB") + let pathURL = URL(filePath: path) + let dbChanged = try Self.hasDatabaseVersionChanged(at: path) + + if reset || dbChanged { + log.info("Versions changed.") + + try? FileManager().removeItem(at: pathURL) + self.db = try Connection(path) + + try db.run(stateTable.create { + $0.column(itemKey, primaryKey: true) + $0.column(containerKey) + $0.column(versionKey) + $0.column(isContainerKey) + $0.column(anchorKey) + $0.column(nameKey) + $0.column(containerPathKey) + }) + + try! db.run("CREATE TABLE IF NOT EXISTS metadata (key TEXT PRIMARY KEY, value TEXT)") + + db.userVersion = Self.dbVersion + self.renewAnchorVersion() + } else { + self.db = try Connection(path) + } + + try FileManager().createDirectory(at: pathURL.deletingLastPathComponent(), + withIntermediateDirectories: true, + attributes: nil) + + } + + func renewAnchorVersion() { + let newValue = String((0..<4).map { _ in "ABCDEFGHIJKLMNOPQRSTUVWXYZ".randomElement()! }) + self.anchorVersion = newValue + } + + func item(_ itemIdentifier: NSFileProviderItemIdentifier) throws -> ItemRow? { + let query = stateTable.filter(itemKey == itemIdentifier.rawValue) + + if let row = try db.pluck(query) { + return ItemRow(row) + } else { + return nil + } + } + + func item(from name: String, containerIdentifier: NSFileProviderItemIdentifier) throws -> ItemRow? { + let query = stateTable.filter(nameKey == name && containerKey == containerIdentifier.rawValue) + + if let row = try db.pluck(query) { + return ItemRow(row) + } else { + return nil + } + } + + func items(in containerIdentifier: NSFileProviderItemIdentifier) throws -> [ItemRow] { + let query = stateTable.filter(containerKey == containerIdentifier.rawValue) + + return try db.prepare(query).map { ItemRow($0) } + } + + func isItemInSet(_ itemIdentifier: NSFileProviderItemIdentifier) throws -> Bool { + try db.scalar(stateTable.filter(itemKey == itemIdentifier.rawValue).exists) + } + + func isContentInSet(_ containerIdentifier: NSFileProviderItemIdentifier) throws -> Bool { + try db.scalar(stateTable.filter(containerKey == containerIdentifier.rawValue).exists) + } + + func containersInSet() throws -> [NSFileProviderItemIdentifier] { + try db.prepare(stateTable.select(distinct: containerKey)).map { + ItemRow($0).item + } + } + + func updateItem(_ row: ItemRow) throws -> [ItemRow] { + log.debug("updateItem \(row.containerPath) \(row.name)") + + var deletedItems: [ItemRow] = [] + + try transaction("updateItem") { + // Check if an item with the same name and container already exists + let itemInContainerQuery = stateTable.filter(nameKey == row.name && containerKey == row.container.rawValue) + + if let existingRow = try db.pluck(itemInContainerQuery) { + let existingItem = ItemRow(existingRow) + + // If replacing a different item, delete the old one and its sub-items if it's a container + if existingItem.item != row.item { + if existingItem.isContainer { + log.debug("Item is replacing container \(existingItem.item.rawValue)") + let subItems = try deleteOldRowsNotInNewSet(within: existingItem.blinkIdentifier(), newSet: []) + deletedItems.append(contentsOf: subItems) + } + deletedItems.append(existingItem) + try db.run(itemInContainerQuery.delete()) + } + } + + // Handle rename if the item is a container and its path or name has changed + if let existingItem = try item(row.item), existingItem.isContainer { + let isPathOrNameChanged = existingItem.containerPath != row.containerPath || existingItem.name != row.name + if isPathOrNameChanged { + let previousPath = (existingItem.containerPath as NSString).appendingPathComponent(existingItem.name) + let newPath = (row.containerPath as NSString).appendingPathComponent(row.name) + log.debug("Item is renaming \(previousPath) -> \(newPath)") + try moveOldRows(within: previousPath, to: newPath) + } + } + + // Insert or replace the item + let upsert = stateTable.insert(or: .replace, + itemKey <- row.item.rawValue, + nameKey <- row.name, + containerKey <- row.container.rawValue, + containerPathKey <- row.containerPath, + versionKey <- row.version.contentVersion, + anchorKey <- row.anchor, + isContainerKey <- row.isContainer) + try db.run(upsert) + } + + return deletedItems + } + + func updateItemsInContainer(_ blinkIdentifier: BlinkFileItemIdentifier, items: [ItemRow]) throws -> [ItemRow] { + var deletedRows: [ItemRow] = [] + try transaction("updateItems") { + log.debug("updateItems \(items.count) InContainer \(blinkIdentifier.path)") + + let newSet = items.map { $0.name } + deletedRows = try deleteOldRowsNotInNewSet(within: blinkIdentifier, newSet: newSet) + + for row in items { + let upsert = stateTable.insert(or: .replace, + itemKey <- row.item.rawValue, + nameKey <- row.name, + containerKey <- row.container.rawValue, + containerPathKey <- row.containerPath, + versionKey <- row.version.contentVersion, + anchorKey <- row.anchor, + isContainerKey <- row.isContainer) + try db.run(upsert) + } + } + return deletedRows + } + + func updateChangedItems(createRows: [ItemRow] = [], + updateRows: [ItemRow] = [], + deleteRows: [ItemRow] = []) throws -> [ItemRow] { + var deletedRows = deleteRows + try transaction("updateChangedItems") { + log.debug("updateChangedItems create \(createRows.count) update: \(updateRows.count) delete: \(deleteRows.count)") + + // Weird case. A container transforms into a regular file. The contents should be marked for deletion. + // This should be handled by the update algorithm. The change is a delete of the previous file type (dir) and then an update of a file type (regfile). + for row in deleteRows { + if row.isContainer { + let container = row.blinkIdentifier() + let deleted = try deleteOldRowsNotInNewSet(within: container, newSet: []) + deletedRows.append(contentsOf: deleted) + } + try db.run(stateTable.filter(itemKey == row.item.rawValue).delete()) + } + + for row in createRows { + let insert = stateTable.insert( + itemKey <- row.item.rawValue, + nameKey <- row.name, + containerKey <- row.container.rawValue, + containerPathKey <- row.containerPath, + versionKey <- row.version.contentVersion, + anchorKey <- row.anchor, + isContainerKey <- row.isContainer + ) + try db.run(insert) + } + + for row in updateRows { + let update = stateTable.filter(itemKey == row.item.rawValue) + .update( + nameKey <- row.name, + containerKey <- row.container.rawValue, + containerPathKey <- row.containerPath, + versionKey <- row.version.contentVersion, + anchorKey <- row.anchor, + isContainerKey <- row.isContainer + ) + try db.run(update) + } + } + return deletedRows + } + + func newestAnchor() throws -> Int { + do { + return try db.scalar(stateTable.select(anchorKey.max)) ?? 0 + } catch { + log.error("Could not get anchor: \(error)") + throw error + } + } + + private func deleteOldRowsNotInNewSet(within container: BlinkFileItemIdentifier, newSet: [String]) throws -> [ItemRow] { + // Fetch rows under the given path level. + var deletedRows: [ItemRow] = [] + + let containerItemsQuery = stateTable.filter(containerPathKey == container.path) + + log.debug("deleteOldRowsNotInNewSet for \(container.path)") + + for row in try db.prepare(containerItemsQuery) { + let itemName = row[nameKey] + if !newSet.contains(itemName) { + log.debug("\(itemName) - deleted") + if row[isContainerKey] == true { + let subPath = (row[containerPathKey] as NSString).appendingPathComponent(itemName) + let subPathQuery = stateTable.filter(containerPathKey.like("\(subPath)%")) + let subPathRows = try db.prepareRowIterator(subPathQuery).map { subRow in + let itemRow = ItemRow(subRow) + log.debug("\(itemRow.containerPath) \(itemRow.name) - deleted") + return itemRow + } + + if !subPathRows.isEmpty { + try db.run(subPathQuery.delete()) + deletedRows.append(contentsOf: subPathRows) + } + } + + try db.run(stateTable.filter(itemKey == row[itemKey]).delete()) + deletedRows.append(ItemRow(row)) + } else { + log.debug("\(itemName) - in new set") + } + } + + return deletedRows + } + + func moveOldRows(within containerPath: String, to newPath: String) throws { + // Find all rows under the specified containerPath + let subItemsQuery = stateTable.filter(containerPathKey.like("\(containerPath)%")) + + for row in try db.prepare(subItemsQuery) { + let currentPath = row[containerPathKey] + + // Replace the portion of the path from previousPath with newPath + let updatedPath = currentPath.replacingOccurrences(of: containerPath, with: newPath, options: .anchored) + log.debug("moveOldRow - \(currentPath) -> \(updatedPath)") + let updateQuery = stateTable.filter(itemKey == row[itemKey]).update(containerPathKey <- updatedPath) + try db.run(updateQuery) + } + } + + + private static func hasDatabaseVersionChanged(at path: String) throws -> Bool { + let tmpDB = try Connection(path) + return tmpDB.userVersion != dbVersion + } + + private func transaction(_ name: String, block: () throws -> Void) throws { + do { + try db.transaction { + try block() + } + } catch { + log.error("\(name) - \(error)") + throw error + } + } +} + +public struct ItemRow { + let item: NSFileProviderItemIdentifier + let name: String + let container: NSFileProviderItemIdentifier + let containerPath: String + let version: NSFileProviderItemVersion + let anchor: Int + let isContainer: Bool + + init(item: NSFileProviderItemIdentifier, + name: String, + container: NSFileProviderItemIdentifier, + containerPath: String, + version: NSFileProviderItemVersion, + isContainer: Bool, + anchor: Int) { + self.item = item + self.name = name + self.container = container + self.containerPath = containerPath + self.version = version + self.isContainer = isContainer + self.anchor = anchor + } + + init(_ row: Row) { + self.item = NSFileProviderItemIdentifier(row[itemKey]) + self.name = row[nameKey] + self.container = NSFileProviderItemIdentifier(row[containerKey]) + self.containerPath = row[containerPathKey] + self.version = NSFileProviderItemVersion(contentVersion: row[versionKey], metadataVersion: row[versionKey]) + self.isContainer = row[isContainerKey] + self.anchor = row[anchorKey] + } + +} + +extension ItemRow { + static func from(_ fileItem: FileProviderItem, at anchorIteration: Int) -> Self { + ItemRow(item: fileItem.itemIdentifier, + name: fileItem.filename, + container: fileItem.parentItemIdentifier, + containerPath: fileItem.parentPath, + version: fileItem.itemVersion, + isContainer: fileItem.contentType == .directory, + anchor: anchorIteration) + } + func blinkIdentifier() -> BlinkFileItemIdentifier { + BlinkFileItemIdentifier(with: self.item, name: self.name, parentIdentifier: self.container, parentPath: self.containerPath) + } +} diff --git a/BlinkFileProvider/BlinkFileProvider.entitlements b/BlinkFileProviderExtension/BlinkFileProviderExtension.entitlements similarity index 100% rename from BlinkFileProvider/BlinkFileProvider.entitlements rename to BlinkFileProviderExtension/BlinkFileProviderExtension.entitlements diff --git a/BlinkFileProvider/Info.plist b/BlinkFileProviderExtension/Info.plist similarity index 94% rename from BlinkFileProvider/Info.plist rename to BlinkFileProviderExtension/Info.plist index 3969141de..79beac8bd 100644 --- a/BlinkFileProvider/Info.plist +++ b/BlinkFileProviderExtension/Info.plist @@ -29,7 +29,7 @@ NSExtensionPointIdentifier com.apple.fileprovider-nonui NSExtensionPrincipalClass - $(PRODUCT_MODULE_NAME).FileProviderExtension + BlinkFileProvider.FileProviderReplicatedExtension diff --git a/Blink/SmarterKeys/KBSound.swift b/BlinkFileProviderExtension/main.swift similarity index 73% rename from Blink/SmarterKeys/KBSound.swift rename to BlinkFileProviderExtension/main.swift index 2dcbb8e9c..ce0e465fc 100644 --- a/Blink/SmarterKeys/KBSound.swift +++ b/BlinkFileProviderExtension/main.swift @@ -1,8 +1,8 @@ -//////////////////////////////////////////////////////////////////////////////// +////////////////////////////////////////////////////////////////////////////////// // // B L I N K // -// Copyright (C) 2016-2019 Blink Mobile Shell Project +// Copyright (C) 2016-2024 Blink Mobile Shell Project // // This file is part of Blink. // @@ -29,25 +29,5 @@ // //////////////////////////////////////////////////////////////////////////////// -import Foundation -import AudioToolbox - -enum KBSound: UInt32, Codable { - case text = 1104 - case delete = 1155 - case modifier = 1156 - - func play() { - AudioServicesPlaySystemSound(self.rawValue) - } - - func playIfPossible() { - if KBSound.isMutted { - return - } - - play() - } - - static var isMutted: Bool = false -} +// This file is intentionally blank. It's needed as an entry point for the file +// provider extension. diff --git a/BlinkFileProviderUI/Base.lproj/MainInterface.storyboard b/BlinkFileProviderExtensionUI/Base.lproj/MainInterface.storyboard similarity index 100% rename from BlinkFileProviderUI/Base.lproj/MainInterface.storyboard rename to BlinkFileProviderExtensionUI/Base.lproj/MainInterface.storyboard diff --git a/BlinkFileProviderUI/DocumentActionViewController.swift b/BlinkFileProviderExtensionUI/DocumentActionViewController.swift similarity index 54% rename from BlinkFileProviderUI/DocumentActionViewController.swift rename to BlinkFileProviderExtensionUI/DocumentActionViewController.swift index 1aefe8467..0f0b26262 100644 --- a/BlinkFileProviderUI/DocumentActionViewController.swift +++ b/BlinkFileProviderExtensionUI/DocumentActionViewController.swift @@ -34,80 +34,80 @@ import UIKit import FileProviderUI class DocumentActionViewController: FPUIActionExtensionViewController { - - @IBOutlet weak var errorLabel: UILabel! - @IBOutlet weak var actionTypeLabel: UILabel! - @IBOutlet weak var doneButton: UIButton! - + + @IBOutlet weak var errorLabel: UILabel! + @IBOutlet weak var actionTypeLabel: UILabel! + @IBOutlet weak var doneButton: UIButton! + override func viewDidLoad() { super.viewDidLoad() - + doneButton.layer.cornerRadius = 10 doneButton.layer.masksToBounds = true } - - override func prepare(forAction actionIdentifier: String, itemIdentifiers: [NSFileProviderItemIdentifier]) { - //identifierLabel?.text = actionIdentifier - actionTypeLabel?.text = "Custom action" - self.dismiss(animated: true, completion: { - self.extensionContext.completeRequest() - if itemIdentifiers.isEmpty { - return - } - let identifier = BlinkItemIdentifier(itemIdentifiers[0]) - guard let rootPath = identifier.rootPath else { - return - } - - // rootPath: ssh:host:root_folder - let components = rootPath.split(separator: ":") - let remoteProtocol = components[0] - - var codeURL: URL - if remoteProtocol == "local" { - codeURL = URL(string: "vscode://local")! - codeURL.appendPathComponent(components[1...].joined(separator: "/")) - codeURL.appendPathComponent(identifier.path) - } else { - codeURL = URL(string: "vscode://sftp/\(components[1])")! - codeURL.appendPathComponent(components[2...].joined(separator: "/")) - codeURL.appendPathComponent(identifier.path) - } - // Open the URL - self.extensionContext.open(codeURL) - }) - } - - override func prepare(forError error: Error) { - errorLabel?.text = error.localizedDescription - actionTypeLabel?.text = "Authenticate" - } - - @IBAction func doneButtonTapped(_ sender: Any) { - // Perform the action and call the completion block. If an unrecoverable error occurs you must still call the completion block with an error. Use the error code FPUIExtensionErrorCode.failed to signal the failure. - extensionContext.completeRequest() - } - + + override func prepare(forAction actionIdentifier: String, itemIdentifiers: [NSFileProviderItemIdentifier]) { + //identifierLabel?.text = actionIdentifier + actionTypeLabel?.text = "Custom action" + self.dismiss(animated: true, completion: { + self.extensionContext.completeRequest() + if itemIdentifiers.isEmpty { + return + } + let identifier = BlinkItemIdentifier(itemIdentifiers[0]) + guard let rootPath = identifier.rootPath else { + return + } + + // rootPath: ssh:host:root_folder + let components = rootPath.split(separator: ":") + let remoteProtocol = components[0] + + var codeURL: URL + if remoteProtocol == "local" { + codeURL = URL(string: "vscode://local")! + codeURL.appendPathComponent(components[1...].joined(separator: "/")) + codeURL.appendPathComponent(identifier.path) + } else { + codeURL = URL(string: "vscode://sftp/\(components[1])")! + codeURL.appendPathComponent(components[2...].joined(separator: "/")) + codeURL.appendPathComponent(identifier.path) + } + // Open the URL + self.extensionContext.open(codeURL) + }) + } + + override func prepare(forError error: Error) { + errorLabel?.text = error.localizedDescription + actionTypeLabel?.text = "Authenticate" + } + + @IBAction func doneButtonTapped(_ sender: Any) { + // Perform the action and call the completion block. If an unrecoverable error occurs you must still call the completion block with an error. Use the error code FPUIExtensionErrorCode.failed to signal the failure. + extensionContext.completeRequest() + } + } struct BlinkItemIdentifier { let path: String let encodedRootPath: String - + var rootPath: String? { guard let rootData = Data(base64Encoded: encodedRootPath), - let rootPath = String(data: rootData, encoding: .utf8) else { - return nil - } - return rootPath + let rootPath = String(data: rootData, encoding: .utf8) else { + return nil + } + return rootPath } // /path/to, name = filename. -> /path/to/filename init(parentItemIdentifier: BlinkItemIdentifier, filename: String) { self.encodedRootPath = parentItemIdentifier.encodedRootPath self.path = (parentItemIdentifier.path as NSString).appendingPathComponent(filename) } - + // /path/to/filename init(_ identifier: NSFileProviderItemIdentifier) { let parts = identifier.rawValue.split(separator: "/", maxSplits: 1, omittingEmptySubsequences: false).map(String.init) diff --git a/BlinkFileProviderUI/Info.plist b/BlinkFileProviderExtensionUI/Info.plist similarity index 100% rename from BlinkFileProviderUI/Info.plist rename to BlinkFileProviderExtensionUI/Info.plist diff --git a/BlinkFileProviderUI/Media.xcassets/Contents.json b/BlinkFileProviderExtensionUI/Media.xcassets/Contents.json similarity index 100% rename from BlinkFileProviderUI/Media.xcassets/Contents.json rename to BlinkFileProviderExtensionUI/Media.xcassets/Contents.json diff --git a/BlinkFileProviderUI/Media.xcassets/app_location.imageset/Contents.json b/BlinkFileProviderExtensionUI/Media.xcassets/app_location.imageset/Contents.json similarity index 100% rename from BlinkFileProviderUI/Media.xcassets/app_location.imageset/Contents.json rename to BlinkFileProviderExtensionUI/Media.xcassets/app_location.imageset/Contents.json diff --git a/BlinkFileProviderUI/Media.xcassets/app_location.imageset/IMG_A2C8A087D26C-1.jpeg b/BlinkFileProviderExtensionUI/Media.xcassets/app_location.imageset/IMG_A2C8A087D26C-1.jpeg similarity index 100% rename from BlinkFileProviderUI/Media.xcassets/app_location.imageset/IMG_A2C8A087D26C-1.jpeg rename to BlinkFileProviderExtensionUI/Media.xcassets/app_location.imageset/IMG_A2C8A087D26C-1.jpeg diff --git a/BlinkFileProviderUI/Media.xcassets/app_location.imageset/IMG_C584F1592DE7-1.jpeg b/BlinkFileProviderExtensionUI/Media.xcassets/app_location.imageset/IMG_C584F1592DE7-1.jpeg similarity index 100% rename from BlinkFileProviderUI/Media.xcassets/app_location.imageset/IMG_C584F1592DE7-1.jpeg rename to BlinkFileProviderExtensionUI/Media.xcassets/app_location.imageset/IMG_C584F1592DE7-1.jpeg diff --git a/BlinkFileProviderTests/BlinkFileProviderTests.swift b/BlinkFileProviderTests/BlinkFileProviderTests.swift new file mode 100644 index 000000000..ec4445727 --- /dev/null +++ b/BlinkFileProviderTests/BlinkFileProviderTests.swift @@ -0,0 +1,825 @@ +////////////////////////////////////////////////////////////////////////////////// +// +// B L I N K +// +// Copyright (C) 2016-2024 Blink Mobile Shell Project +// +// This file is part of Blink. +// +// Blink is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Blink is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Blink. If not, see . +// +// In addition, Blink is also subject to certain additional terms under +// GNU GPL version 3 section 7. +// +// You should have received a copy of these additional terms immediately +// following the terms and conditions of the GNU General Public License +// which accompanied the Blink Source Code. If not, see +// . +// +//////////////////////////////////////////////////////////////////////////////// + + +import XCTest +import BlinkFiles +import Combine +import SSH +import UniformTypeIdentifiers + +@testable import BlinkFileProvider + +final class BlinkFileProviderTests: XCTestCase { + func testEnumerator() throws { + self.continueAfterFailure = false + let connection = connection(path: Self.testPath) + let workingSet = try workingSet() + let enumerator = FileProviderReplicatedEnumerator(for: .rootContainer, + workingSet: workingSet, + connection: connection, + logger: testsLogger("enumeratorFor rootContainer")) + + let expectEnumerateRoot = self.expectation(description: "Root enumerated") + + enumerator.enumerateItems( + for: TestEnumeratorObserver( + didEnumerate: { items in + XCTAssertTrue(items.count > 0) + }, + finishEnumerating: { _ in + expectEnumerateRoot.fulfill() + }, + finishEnumeratingWithError: { error in + XCTFail("Enumeration failed") + }), + startingAt: NSFileProviderPage(Data()) + ) + + wait(for: [expectEnumerateRoot]) + + // Test container and symlink enumeration. + } + + func testWorkingSetChanges() throws { + self.continueAfterFailure = false + + // We need to use the fp as there is no way to obtain a different enumerator (we need to hit the DB, and that's what the enumeratorFor does. + let location = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] + let db = try WorkingSetDatabase(path: location.appendingPathComponent("workingset.tests.db").path(), reset: true) + let ws = try WorkingSet(domain: nil, db: db, logger: testsLogger("WorkingSet")) + var connection = connection(path: Self.testPath) + var fp = FileProviderReplicatedExtension(connection: connection, workingSet: ws, temporaryDirectoryURL: location) + + let enumerator = try fp.enumerator(for: .rootContainer, request: NSFileProviderRequest()) +// let enumerator = try FileProviderReplicatedEnumerator(for: BlinkFileItemIdentifier.rootContainer, workingSet: ws, connection: connection) + // There is no identifier for the container until it is enumerated. + + // 1. Before enumeration, the enumerators are not Active for changes. + // In code, this wasn't true because the rootEnumerator was always Active. But we can try otherwise, as now previous content cannot be reused. + let expectNoActiveEnumerators = self.expectation(description: "No active enumerators") + + ws.prepareChanges(onCompletion: { changes in + XCTAssertTrue(changes.isEmpty) + expectNoActiveEnumerators.fulfill() + }) + + wait(for: [expectNoActiveEnumerators]) + + // 2. Trigger enumeration. + let expectEnumerateRoot = self.expectation(description: "Root enumerated") + var docsContainerIdentifier: NSFileProviderItemIdentifier! + enumerator.enumerateItems( + for: TestEnumeratorObserver( + didEnumerate: { items in + docsContainerIdentifier = items.first(where: { $0.filename == "docs" })!.itemIdentifier + XCTAssertFalse(items.contains { $0.filename == "." } ) + XCTAssertTrue(items.count > 0) + }, + finishEnumerating: { _ in + expectEnumerateRoot.fulfill() + }, + finishEnumeratingWithError: { error in + XCTFail("Enumeration failed with \(error)") + }), + startingAt: NSFileProviderPage(Data()) + ) + + wait(for: [expectEnumerateRoot]) + + let expectEnumerateContainer = self.expectation(description: "Container enumerated") + let containerEnumerator = try fp.enumerator(for: docsContainerIdentifier, request: NSFileProviderRequest()) + + containerEnumerator.enumerateItems( + for: TestEnumeratorObserver(finishEnumerating: { _ in expectEnumerateContainer.fulfill() }), + startingAt: NSFileProviderPage(Data()) + ) + wait(for: [expectEnumerateContainer]) + + var items = try db.items(in: docsContainerIdentifier) + + // 3. No changes after recent enumeration. + let expectNoChanges = self.expectation(description: "No changes in root") + + ws.prepareChanges(onCompletion: { changes in + XCTAssertTrue(changes.isEmpty) + expectNoChanges.fulfill() + }) + + wait(for: [expectNoChanges]) + + enumerator.invalidate() + containerEnumerator.invalidate() + + // 4. Prepare changes. + // Instead of making changes to a location, we are changing the rootContainer to a different cloned location with the changes. + connection = self.connection(path: Self.testPathChanges) + fp = FileProviderReplicatedExtension(connection: connection, workingSet: ws, temporaryDirectoryURL: location) + let enumeratorChanges = try fp.enumerator(for: .rootContainer, request: NSFileProviderRequest()) + + let expectChanges = self.expectation(description: "Detect Changes") + + ws.prepareChanges(onCompletion: { changes in + XCTAssertTrue(changes.creates.count == 2) + // "." is a change too on a different folder + XCTAssertTrue(changes.updates.count == 2 + 1) + XCTAssertTrue(changes.deletions.count == 3) + expectChanges.fulfill() + }) + + wait(for: [expectChanges]) + + // 5. The WorkingSet updates its state when there are changes. When requested an enumeration, test different cases. + let expectStateChange = self.expectation(description: "WorkingSet State change with enumerator") + let anchor = ws.anchor + + ws.prepareChangesAndSignalEnumerator() + + let wsEnumerator = try fp.enumerator(for: .workingSet, request: NSFileProviderRequest()) + + var updates = 0 + var deletions = 0 + DispatchQueue.global(qos: .background).asyncAfter(deadline: .now() + 2.0) { + wsEnumerator.enumerateChanges!( + for: TestEnumeratorChangeObserver( + didUpdate: { items in + XCTAssertFalse(items.contains { $0.filename == "." }) + updates += items.count + }, + didDeleteItems: { items in + deletions += items.count + }, + finishEnumeratingChanges: { (newAnchor, moreComing) in + XCTAssertTrue(anchor.iteration + 1 == newAnchor.iteration) + XCTAssertTrue(updates == 4) + // After the commit, the elements from docs directory should be released. + XCTAssertTrue(deletions > 3) + + expectStateChange.fulfill() + }, + finishEnumeratingWithError: { error in + XCTFail("enumerateChanges failed with \(error)") + }), + from: anchor) + } + + wait(for: [expectStateChange]) + } + + func testFetchContents() throws { + let fp = try fileProviderExtension() + let request = NSFileProviderRequest() + let version: NSFileProviderItemVersion? = nil + + let expectIdentifier = self.expectation(description: "No identifier") + let enumerator = try fp.enumerator(for: .rootContainer, request: request) + var itemIdentifier: NSFileProviderItemIdentifier! + enumerator.enumerateItems( + for: TestEnumeratorObserver( + didEnumerate: { items in + itemIdentifier = items.first(where: { $0.filename == "image.jpg" })!.itemIdentifier + expectIdentifier.fulfill() + } + ), + startingAt: NSFileProviderPage(Data()) + ) + wait(for: [expectIdentifier]) + + let expectDownload = self.expectation(description: "Download") + var progress = fp.fetchContents(for: itemIdentifier, + version: version, + request: request) { (url, fileItem, error) in + if let error = error { + XCTFail("Download failed \(error)") + return + } + + let url = url! + let fileItem = fileItem! + expectDownload.fulfill() + } + wait(for: [expectDownload]) + + XCTAssertTrue(progress.isFinished) + + let noItemIdentifier = NSFileProviderItemIdentifier("xxx.jpg") + let expectNoSuchFile = self.expectation(description: "No such file") + progress = fp.fetchContents(for: noItemIdentifier, + version: version, + request: request) { (url, fileItem, error) in + XCTAssertTrue(error != nil && (error! as! NSFileProviderError).code == NSFileProviderError.noSuchItem) + XCTAssertTrue(url == nil) + expectNoSuchFile.fulfill() + } + wait(for: [expectNoSuchFile]) + XCTAssertFalse(progress.isFinished) + } + + func testCreateEmptyFileItem() throws { + self.continueAfterFailure = false + let fp = try fileProviderExtension() + + let testItemTemplate = TestFileProviderItem(itemIdentifier: NSFileProviderItemIdentifier("empty"), parentItemIdentifier: .rootContainer, filename: "empty", contentType: .data) + + // Regular file + let expectFileCreated = self.expectation(description: "Item created") + var file: NSFileProviderItem! + var _progress = fp.createItem(basedOn: testItemTemplate, + fields: [.filename, .parentItemIdentifier, .creationDate, .contentModificationDate, .fileSystemFlags, .typeAndCreator], + contents: nil, + request: NSFileProviderRequest()) { (createdItem, pendingFields, shouldFetch, error) in + XCTAssertNil(error) + XCTAssertTrue(pendingFields.isEmpty) + XCTAssertTrue(createdItem!.filename == "empty") + file = createdItem! + expectFileCreated.fulfill() + } + wait(for: [expectFileCreated]) + + let expectDeleteFile = self.expectation(description: "Item deleted") + _progress = fp.deleteItem(identifier: file.itemIdentifier, + baseVersion: file.itemVersion!, + options: NSFileProviderDeleteItemOptions([]), + request: NSFileProviderRequest()) { error in + XCTAssertNil(error) + expectDeleteFile.fulfill() + } + wait(for: [expectDeleteFile]) + } + + func testCreateStructure() throws { + self.continueAfterFailure = false + let fp = try fileProviderExtension() + + // Regular folder + let testDirectoryTemplate = TestFileProviderItem(itemIdentifier: NSFileProviderItemIdentifier("tmp"), parentItemIdentifier: .rootContainer, filename: "tmp", contentType: .folder) + let expectFolderCreated = self.expectation(description: "Folder created") + var dir: NSFileProviderItem! + var _progress = fp.createItem(basedOn: testDirectoryTemplate, fields: [], contents: nil, request: NSFileProviderRequest()) { (createdItem, pendingFields, shouldFetch, error) in + XCTAssertNil(error) + XCTAssertTrue(createdItem!.filename == "tmp") + dir = createdItem! + expectFolderCreated.fulfill() + } + wait(for: [expectFolderCreated]) + + let fileURL = Bundle.main.url(forResource: "term", withExtension: "html")! + let testItemTemplate = TestFileProviderItem(itemIdentifier: NSFileProviderItemIdentifier("test.html"), parentItemIdentifier: dir.itemIdentifier, filename: "test.html", contentType: .data) + + // Regular file + let expectFileCreated = self.expectation(description: "Item created") + _progress = fp.createItem(basedOn: testItemTemplate, + // TODO Flags + fields: [.contents, .filename], + contents: fileURL, options: [.mayAlreadyExist], + request: NSFileProviderRequest()) { (createdItem, pendingFields, shouldFetch, error) in + XCTAssertNil(error) + XCTAssertTrue(pendingFields.isEmpty) + XCTAssertTrue(createdItem!.filename == "test.html") + expectFileCreated.fulfill() + } + wait(for: [expectFileCreated]) + } + + func testFileItemCreate() throws { + self.continueAfterFailure = false + var fp = try fileProviderExtension() + + let fileURL = Bundle.main.url(forResource: "term", withExtension: "html")! + let testItemTemplate = TestFileProviderItem(itemIdentifier: NSFileProviderItemIdentifier("test.html"), parentItemIdentifier: .rootContainer, filename: "test.html", contentType: .data) + + // Regular file + let expectFileCreated = self.expectation(description: "Item created") + var fileItem: NSFileProviderItem! + var _progress = fp.createItem(basedOn: testItemTemplate, + // TODO Flags + fields: [.contents, .filename], + contents: fileURL, options: [.mayAlreadyExist], + request: NSFileProviderRequest()) { (createdItem, pendingFields, shouldFetch, error) in + XCTAssertNil(error) + XCTAssertTrue(pendingFields.isEmpty) + XCTAssertTrue(createdItem!.filename == "test.html") + fileItem = createdItem! + expectFileCreated.fulfill() + } + wait(for: [expectFileCreated]) + + // TODO Create in non-existing container. + + // Same create should return same FileItem within the Collision. + let expectCollisionWithSameFileItem = self.expectation(description: "Collision with same File item") + _progress = fp.createItem(basedOn: testItemTemplate, + // TODO Flags + fields: [.contents, .filename], + contents: fileURL, options: [], + request: NSFileProviderRequest()) { (createdItem, pendingFields, shouldFetch, error) in + XCTAssertNotNil(error) + let error = error as! NSFileProviderError + let itemInError = error.userInfo[NSFileProviderErrorItemKey] as! NSFileProviderItem + XCTAssertTrue(itemInError.itemIdentifier == fileItem.itemIdentifier) + expectCollisionWithSameFileItem.fulfill() + } + wait(for: [expectCollisionWithSameFileItem]) + + // Resetting the fp, we should get a different FileItem within the Collision. + fp = try fileProviderExtension() + let expectCollisionDifferentFileItem = self.expectation(description: "Collision with different File item") + _progress = fp.createItem(basedOn: testItemTemplate, + // TODO Flags + fields: [.contents, .filename], + contents: fileURL, options: [], + request: NSFileProviderRequest()) { (createdItem, pendingFields, shouldFetch, error) in + XCTAssertNotNil(error) + let error = error as! NSFileProviderError + let itemInError = error.userInfo[NSFileProviderErrorItemKey] as! NSFileProviderItem + XCTAssertFalse(itemInError.itemIdentifier == fileItem.itemIdentifier) + fileItem = itemInError + expectCollisionDifferentFileItem.fulfill() + } + wait(for: [expectCollisionDifferentFileItem]) + + let expectDeleteFile = self.expectation(description: "Item deleted") + _progress = fp.deleteItem(identifier: fileItem.itemIdentifier, + baseVersion: fileItem.itemVersion!, + options: NSFileProviderDeleteItemOptions([]), + request: NSFileProviderRequest()) { error in + XCTAssertNil(error) + expectDeleteFile.fulfill() + } + wait(for: [expectDeleteFile]) + } + + func testDirectoryCreate() throws { + self.continueAfterFailure = false + var fp = try fileProviderExtension() + + // Regular folder + let untitledDirectoryTemplate = TestFileProviderItem(itemIdentifier: NSFileProviderItemIdentifier("dir"), parentItemIdentifier: .rootContainer, filename: "untitled folder", contentType: .folder) + let expectFolderCreated = self.expectation(description: "Folder created") + var dir: NSFileProviderItem! + var _progress = fp.createItem(basedOn: untitledDirectoryTemplate, fields: [.filename], contents: nil, request: NSFileProviderRequest()) { (createdItem, pendingFields, shouldFetch, error) in + XCTAssertNil(error) + XCTAssertTrue(pendingFields.isEmpty) + XCTAssertTrue(createdItem?.filename == "untitled folder") + dir = createdItem + expectFolderCreated.fulfill() + } + wait(for: [expectFolderCreated]) + + let expectFolderRename = self.expectation(description: "Folder rename") + let testDirectoryTemplate = TestFileProviderItem(itemIdentifier: dir.itemIdentifier, parentItemIdentifier: dir.parentItemIdentifier, filename: "Test Directory", contentType: .folder) + _progress = fp.modifyItem(testDirectoryTemplate, baseVersion: dir.itemVersion!, changedFields: [.filename], contents: nil, request: NSFileProviderRequest()) { (modifiedItem, pendingFields, shouldFetch, error) in + XCTAssertNil(error) + XCTAssertTrue(pendingFields.isEmpty) + XCTAssertTrue(modifiedItem?.filename == "Test Directory") + dir = modifiedItem + expectFolderRename.fulfill() + } + wait(for: [expectFolderRename]) + + let expectRecreate = self.expectation(description: "Recreate") + _progress = fp.createItem(basedOn: testDirectoryTemplate, fields: [.filename], contents: nil, request: NSFileProviderRequest()) { (createdItem, pendingFields, shouldFetch, error) in + XCTAssertNil(error) + XCTAssertTrue(pendingFields.isEmpty) + XCTAssertTrue(createdItem?.itemIdentifier == dir.itemIdentifier) + expectRecreate.fulfill() + } + wait(for: [expectRecreate]) + } + + func testFileItemOperations() throws { + self.continueAfterFailure = false + let fp = try fileProviderExtension() + + try fileItemOperations(rootContainer: .rootContainer, fp: fp) + } + + func fileItemOperations(rootContainer: NSFileProviderItemIdentifier, fp: FileProviderReplicatedExtension) throws { + let fileURL = Bundle.main.url(forResource: "term", withExtension: "html")! + let testItemTemplate = TestFileProviderItem(itemIdentifier: NSFileProviderItemIdentifier("test.html"), parentItemIdentifier: rootContainer, filename: "test.html", contentType: .data) + + // Regular file + let expectFileCreated = self.expectation(description: "Item created") + var file: NSFileProviderItem! + var _progress = fp.createItem(basedOn: testItemTemplate, + // TODO Flags + fields: [.contents, .filename], + contents: fileURL, options: [.mayAlreadyExist], + request: NSFileProviderRequest()) { (createdItem, pendingFields, shouldFetch, error) in + XCTAssertNil(error) + XCTAssertTrue(pendingFields.isEmpty) + XCTAssertTrue(createdItem!.filename == "test.html") + file = createdItem! + expectFileCreated.fulfill() + } + + // Regular folder + let testDirectoryTemplate = TestFileProviderItem(itemIdentifier: NSFileProviderItemIdentifier("dir"), parentItemIdentifier: rootContainer, filename: "dir", contentType: .folder) + let expectFolderCreated = self.expectation(description: "Folder created") + var dir: NSFileProviderItem! + _progress = fp.createItem(basedOn: testDirectoryTemplate, fields: [], contents: nil, request: NSFileProviderRequest()) { (createdItem, pendingFields, shouldFetch, error) in + XCTAssertNil(error) + dir = createdItem + expectFolderCreated.fulfill() + } + wait(for: [expectFileCreated, expectFolderCreated]) + + // Modify content and filename + + // This should check the previous has been deleted too. + let testModifiedItemTemplate = TestFileProviderItem(itemIdentifier: file.itemIdentifier, + parentItemIdentifier: dir.itemIdentifier, + filename: "modified.html", contentType: .data) + + let expectUpdatedContentAndFilename = self.expectation(description: "Update content") + _progress = fp.modifyItem(testModifiedItemTemplate, + baseVersion: file.itemVersion!, + changedFields: [.contents, .filename, .parentItemIdentifier], + contents: fileURL, + request: NSFileProviderRequest()) { (modifiedItem, pendingFields, shouldFetch, error) in + XCTAssertNil(error) + // Our upload is shared with create, and regular parameters can change at once. + XCTAssertTrue(pendingFields.isEmpty) + XCTAssertTrue(modifiedItem!.itemIdentifier == file.itemIdentifier) + XCTAssertTrue(modifiedItem!.parentItemIdentifier == dir.itemIdentifier) + file = modifiedItem + expectUpdatedContentAndFilename.fulfill() + } + wait(for: [expectUpdatedContentAndFilename]) + +// // Modify parent down. +// let expectReparentingUp = self.expectation(description: "Reparenting") +// _progress = fp.modifyItem(testModifiedItemTemplate, +// baseVersion: file.itemVersion!, +// changedFields: [.parentItemIdentifier], +// contents: nil, +// request: NSFileProviderRequest()) { (modifiedItem, pendingFields, shouldFetch, error) in +// XCTAssertNil(error) +// XCTAssertTrue(pendingFields.isEmpty) +// XCTAssertTrue(modifiedItem!.filename == "modified.html") +// XCTAssertTrue(modifiedItem!.itemIdentifier.rawValue == "dir/modified.html") +// XCTAssertTrue(modifiedItem!.parentItemIdentifier.rawValue == "dir") +// file = modifiedItem +// expectReparentingUp.fulfill() +// } +// wait(for: [expectReparentingUp]) + + let expectReparentingDown = self.expectation(description: "Reparenting") + + let testReparentItemTemplate = TestFileProviderItem(itemIdentifier: file.itemIdentifier, + parentItemIdentifier: rootContainer, + filename: "test.html", contentType: .data) + _progress = fp.modifyItem(testReparentItemTemplate, + baseVersion: file.itemVersion!, + changedFields: [.parentItemIdentifier, .filename], + contents: nil, + request: NSFileProviderRequest()) { (modifiedItem, pendingFields, shouldFetch, error) in + XCTAssertNil(error) + XCTAssertTrue(pendingFields.isEmpty) + XCTAssertTrue(modifiedItem!.filename == "test.html") + XCTAssertTrue(modifiedItem!.itemIdentifier == file.itemIdentifier) + XCTAssertTrue(modifiedItem!.parentItemIdentifier == rootContainer) + file = modifiedItem + expectReparentingDown.fulfill() + } + wait(for: [expectReparentingDown]) + + + let expectDeleteFile = self.expectation(description: "Item deleted") + _progress = fp.deleteItem(identifier: file.itemIdentifier, + baseVersion: file.itemVersion!, + options: NSFileProviderDeleteItemOptions([]), + request: NSFileProviderRequest()) { error in + XCTAssertNil(error) + expectDeleteFile.fulfill() + } + wait(for: [expectDeleteFile]) + } + + func testSymlinkOperations() throws { + // Browse the structure. + self.continueAfterFailure = false + let fp = try fileProviderExtension(rootPath: "sftp:localhost:~/fps/fps_symlinks") + let request = NSFileProviderRequest() + + let expectEnumerateRoot = self.expectation(description: "Root enumerated") + let enumerator = try fp.enumerator(for: .rootContainer, request: request) + var symlinkRootItem: NSFileProviderItem! + var directoryItem: NSFileProviderItem! + enumerator.enumerateItems( + for: TestEnumeratorObserver( + didEnumerate: { items in + XCTAssertTrue(items.count > 0) + symlinkRootItem = items.first(where: { $0.filename == "test_link" }) + XCTAssertTrue(symlinkRootItem.contentType == .directory) + directoryItem = items.first(where: { $0.filename == "dir" }) + expectEnumerateRoot.fulfill() + } + ), + startingAt: NSFileProviderPage(Data()) + ) + wait(for: [expectEnumerateRoot]) + + let expectEnumerateSymlink = self.expectation(description: "Symlink Root") + let symlinkEnumerator = try fp.enumerator(for: symlinkRootItem.itemIdentifier, request: request) + symlinkEnumerator.enumerateItems( + for: TestEnumeratorObserver( + didEnumerate: { items in + XCTAssertTrue(items.count > 0) + expectEnumerateSymlink.fulfill() + } + ), + startingAt: NSFileProviderPage(Data()) + ) + wait(for: [expectEnumerateSymlink]) + + // Allow delete. Content should be untouched. Note we do not allow to create the item, so this part of the test is not automated. + // let expectSymlinkDeleted = self.expectation(description: "Symlink deleted") + // fp.deleteItem(identifier: symlinkRootItem.itemIdentifier, + // baseVersion: symlinkRootItem.itemVersion!, + // options: [], + // request: request) { error in + // XCTAssertNil(error) + // expectSymlinkDeleted.fulfill() + // } + // wait(for: [expectSymlinkDeleted]) + + // Operations inside the symlink (rename, uploads, etc...) - this is more on the SFTP side though, but + // good to know the limitations now. + // try fileItemOperations(rootContainer: symlinkRootItem.itemIdentifier, fp: fp) + + // Operations to the symlink: + // - Cannot rename or move the symlink (it will break) - you can move the symlink, you cannot change where it points to. + // - What happens if the link breaks after the move? It should come from the changes, but not sure it is going to like it. + +// let testRenameSymlinkTemplate = TestFileProviderItem(itemIdentifier: symlinkRootItem.itemIdentifier, +// parentItemIdentifier: .rootContainer, +// filename: "other_link", +// contentType: .folder) +// let expectRenameSymlink = self.expectation(description: "Rename symlink") +// var _progress = fp.modifyItem(testRenameSymlinkTemplate, +// baseVersion: symlinkRootItem.itemVersion!, +// changedFields: [.filename], +// contents: nil, +// request: request) { (modifiedItem, pendingFields, shouldFetch, error) in +// XCTAssertNil(error) +// XCTAssertTrue(modifiedItem!.itemIdentifier == symlinkRootItem.itemIdentifier) +// XCTAssertTrue(modifiedItem!.filename == "other_link") +// symlinkRootItem = modifiedItem +// expectRenameSymlink.fulfill() +// } +// wait(for: [expectRenameSymlink]) +// +// // Reparent the symlink. +// let testReparentSymlinkTemplate = TestFileProviderItem(itemIdentifier: symlinkRootItem.itemIdentifier, +// parentItemIdentifier: directoryItem.itemIdentifier, +// filename: "test_link", +// contentType: .folder) +// let expectReparentSymlink = self.expectation(description: "Reparent symlink") +// _progress = fp.modifyItem(testReparentSymlinkTemplate, +// baseVersion: symlinkRootItem.itemVersion!, +// changedFields: [.filename, .parentItemIdentifier], +// contents: nil, +// request: request) { (modifiedItem, pendingFields, shouldFetch, error) in +// XCTAssertNil(error) +// // Could we return a different item in this? +// } + + // Changes should be seen in the other enumerator too - the test here + // would be to have get the same reference to the Destination. + } + + func testDeleteItem() throws { + self.continueAfterFailure = false + let fp = try fileProviderExtension() + + let item = NSFileProviderItemIdentifier("docs") + let version = NSFileProviderItemVersion() + let options = NSFileProviderDeleteItemOptions([.recursive]) + let request = NSFileProviderRequest() + + let expectFolderDeleted = self.expectation(description: "Folder deleted") + let _prorgress = fp.deleteItem(identifier: item, + baseVersion: version, + options: options, + request: request) { error in + XCTAssertNil(error) + expectFolderDeleted.fulfill() + } + + wait(for: [expectFolderDeleted]) + } + + func testCleanupTmpFiles() throws { + self.continueAfterFailure = false + var cancellables = Set() + let fp = try fileProviderExtension() + + let tmpFileNameOne = ".blink.tmp.one" + let tmpFileNameTwo = ".blink.tmp.two" + + let expectTmpFileOneCreated = self.expectation(description: "File One created") + fp.rootTranslator + .flatMap { translator -> AnyPublisher in + translator.create(name: tmpFileNameOne, mode: S_IRWXU) } + .flatMap { file in + file.write(Data(repeating: 0, count:8).withUnsafeBytes { DispatchData(bytes: $0) }, max: 8) + .flatMap { _ in file.close() } } + .assertNoFailure() + .sink { _ in + expectTmpFileOneCreated.fulfill() + } + .store(in: &cancellables) + + let expectTmpFileTwoCreated = self.expectation(description: "File Two created") + fp.rootTranslator + .flatMap { $0.create(name: tmpFileNameTwo, mode: S_IRWXU) } + .flatMap { file in + file.write(Data(repeating: 0, count:16).withUnsafeBytes { DispatchData(bytes: $0) }, max: 16) + .flatMap { _ in file.close() } } + .assertNoFailure() + .sink { _ in + expectTmpFileTwoCreated.fulfill() + } + .store(in: &cancellables) + wait(for: [expectTmpFileOneCreated, expectTmpFileTwoCreated]) + cancellables = [] + let expectTmpFileTwoWriteModificationDate = self.expectation(description: "File Two modified date changed") + + fp.rootTranslator + .flatMap { $0.cloneWalkTo(tmpFileNameTwo) } + .flatMap { translator in + var newAttributes: BlinkFiles.FileAttributes = [:] + newAttributes[.modificationDate] = Date().addingTimeInterval(-6000) + return translator.wstat(newAttributes) + } + .assertNoFailure() + .sink { _ in expectTmpFileTwoWriteModificationDate.fulfill() } + .store(in: &cancellables) + + wait(for: [expectTmpFileTwoWriteModificationDate]) + + cancellables = [] + let cancelCleanup = fp.cleanUpOldTmpFiles() + RunLoop.current.run(until: Date(timeIntervalSinceNow: 2)) + + let expectFileTwoMissing = self.expectation(description: "File Two missing") + + fp.rootTranslator + .flatMap { $0.cloneWalkTo(tmpFileNameTwo) } + .sink( + receiveCompletion: { completion in + guard case .failure(_) = completion else { + XCTFail("File should not exist") + return + } + expectFileTwoMissing.fulfill() + }, + receiveValue: { _ in } + ) + .store(in: &cancellables) + + let expectFileOneExists = self.expectation(description: "File One exists") + + fp.rootTranslator + .flatMap { $0.cloneWalkTo(tmpFileNameOne) } + .assertNoFailure() + .flatMap { $0.remove() } + .assertNoFailure() + .sink { _ in expectFileOneExists.fulfill() } + .store(in: &cancellables) + + wait(for: [expectFileOneExists, expectFileTwoMissing]) + } +} + +extension BlinkFileProviderTests { + static let testPath = "sftp:localhost:~/fps/fps" + static let testPathChanges = "sftp:localhost:~/fps/fps_changes" + + func testsLogger(_ component: String) -> BlinkLogger { + BlinkLogger(component, handlers: [BlinkLoggingHandlers.print]) + } + + func workingSet() throws -> WorkingSet { + let location = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] + let db = try WorkingSetDatabase(path: location.appendingPathComponent("workingset.tests.db").path(), reset: true) + return try WorkingSet(domain: nil, db: db, logger: BlinkLogger("WorkingSet", handlers: [BlinkLoggingHandlers.print])) + } + + func connection(path: String) -> FilesTranslatorConnection { + let providerPath = try! BlinkFileProviderPath(path) + let connection = FilesTranslatorConnection(providerPath: providerPath, configurator: TestFactoryConfigurator()) + return connection + } + + func fileProviderExtension(rootPath: String? = nil) throws -> FileProviderReplicatedExtension { + let workingSet = try workingSet() + let connection = connection(path: rootPath ?? Self.testPath) + let location = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] + + return FileProviderReplicatedExtension(connection: connection, workingSet: workingSet, temporaryDirectoryURL: location) + } +} + +class TestEnumeratorObserver: NSObject, NSFileProviderEnumerationObserver { + let _didEnumerate: (([any NSFileProviderItemProtocol]) -> Void)? + let _finishEnumerating: ((NSFileProviderPage?) -> Void)? + let _finishEnumeratingWithError: ((any Error) -> Void)? + + init(didEnumerate: ( ([any NSFileProviderItemProtocol]) -> Void)? = nil, + finishEnumerating: ( (NSFileProviderPage?) -> Void)? = nil, + finishEnumeratingWithError: ( (any Error) -> Void)? = nil) { + self._didEnumerate = didEnumerate + self._finishEnumerating = finishEnumerating + self._finishEnumeratingWithError = finishEnumeratingWithError + } + + func didEnumerate(_ updatedItems: [any NSFileProviderItemProtocol]) { + _didEnumerate?(updatedItems) + } + + func finishEnumerating(upTo nextPage: NSFileProviderPage?) { + _finishEnumerating?(nextPage) + } + + func finishEnumeratingWithError(_ error: any Error) { + _finishEnumeratingWithError?(error) + } +} + +class TestEnumeratorChangeObserver: NSObject, NSFileProviderChangeObserver { + let _didUpdate: (([any NSFileProviderItemProtocol]) -> Void)? + let _didDeleteItems: (([NSFileProviderItemIdentifier]) -> Void)? + let _finishEnumeratingChanges: ((NSFileProviderSyncAnchor, Bool) -> Void)? + let _finishEnumeratingWithError: ((Error) -> Void)? + + init(didUpdate: (([any NSFileProviderItemProtocol]) -> Void)? = nil, + didDeleteItems: (([NSFileProviderItemIdentifier]) -> Void)? = nil, + finishEnumeratingChanges: ((NSFileProviderSyncAnchor, Bool) -> Void)? = nil, + finishEnumeratingWithError: ((Error) -> Void)? = nil + ) { + self._didUpdate = didUpdate + self._didDeleteItems = didDeleteItems + self._finishEnumeratingChanges = finishEnumeratingChanges + self._finishEnumeratingWithError = finishEnumeratingWithError + } + + func didUpdate(_ updatedItems: [any NSFileProviderItemProtocol]) { + _didUpdate?(updatedItems) + } + + func didDeleteItems(withIdentifiers deletedItemIdentifiers: [NSFileProviderItemIdentifier]) { + _didDeleteItems?(deletedItemIdentifiers) + } + + func finishEnumeratingChanges(upTo anchor: NSFileProviderSyncAnchor, moreComing: Bool) { + _finishEnumeratingChanges?(anchor, moreComing) + } + + func finishEnumeratingWithError(_ error: any Error) { + _finishEnumeratingWithError?(error) + } +} + +class TestFileProviderItem: NSObject, NSFileProviderItem { + var itemIdentifier: NSFileProviderItemIdentifier + var parentItemIdentifier: NSFileProviderItemIdentifier + var filename: String + var contentType: UTType + + init(itemIdentifier: NSFileProviderItemIdentifier, parentItemIdentifier: NSFileProviderItemIdentifier, filename: String, contentType: UTType) { + self.itemIdentifier = itemIdentifier + self.parentItemIdentifier = parentItemIdentifier + self.filename = filename + self.contentType = contentType + } +} diff --git a/BlinkFileProviderTests/FileTranslatorConnectionTests.swift b/BlinkFileProviderTests/FileTranslatorConnectionTests.swift new file mode 100644 index 000000000..cf9926f0a --- /dev/null +++ b/BlinkFileProviderTests/FileTranslatorConnectionTests.swift @@ -0,0 +1,298 @@ +////////////////////////////////////////////////////////////////////////////////// +// +// B L I N K +// +// Copyright (C) 2016-2024 Blink Mobile Shell Project +// +// This file is part of Blink. +// +// Blink is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Blink is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Blink. If not, see . +// +// In addition, Blink is also subject to certain additional terms under +// GNU GPL version 3 section 7. +// +// You should have received a copy of these additional terms immediately +// following the terms and conditions of the GNU General Public License +// which accompanied the Blink Source Code. If not, see +// . +// +//////////////////////////////////////////////////////////////////////////////// + + + +import Combine +import Foundation +import XCTest + +import BlinkFiles +import SSH +@testable import BlinkFileProvider + + +final class FileTranslatorFactoryTests: XCTestCase { + override func setUpWithError() throws { + BlinkLogging.handle(BlinkLoggingHandlers.print) + } + + func testSFTPFactory() throws { + // The issue is that the SSHClient is dropped all the time. This shouldn't happen, the client should be maintained, and only + // reset when the connection is closed. + let providerPath = try BlinkFileProviderPath(TestFactoryConfigurator.LocalTestPath) + + var rootTranslatorPublisher = try FileTranslatorFactory.rootTranslator(for: providerPath, configurator: TestFactoryConfigurator()) + .print("rootTranslatorPublisher") + var rootTranslator: Translator? = nil + + print("First Operation") + let expectFirstOperation = expectation(description: "First Operation") + let c1 = rootTranslatorPublisher.flatMap { t in + rootTranslator = t + return t.cloneWalkTo("fps") + } + .flatMap { $0.directoryFilesAndAttributes() } + .assertNoFailure() + //.print("first operation") + .sink { attrs in + print("First Operation \(attrs.count) elements") + expectFirstOperation.fulfill() + } + + wait(for: [expectFirstOperation], timeout: 2) + + print("Second Operation") + let expectSecondOperation = expectation(description: "Second Operation") + RunLoop.current.run(until: Date(timeIntervalSinceNow: 2)) + + let c2 = Just(rootTranslator!).flatMap { $0.cloneWalkTo("fps") } + .flatMap { $0.directoryFilesAndAttributes() } + .assertNoFailure() + .sink { attrs in + print("Second Operation \(attrs.count) elements") + rootTranslator = nil + expectSecondOperation.fulfill() + } + wait(for: [expectSecondOperation], timeout: 2) + + print("Third Operation") + let expectThirdOperation = expectation(description: "Third operation") + let c3 = rootTranslatorPublisher.flatMap { $0.cloneWalkTo("fps") } + .flatMap { $0.directoryFilesAndAttributes() } + .assertNoFailure() + .sink { attrs in + print("Third Operation \(attrs.count) elements") + expectThirdOperation.fulfill() + } + wait(for: [expectThirdOperation], timeout: 2) + + print("Fourth Operation - multiple connections at the same time") + rootTranslatorPublisher = try FileTranslatorFactory.rootTranslator(for: providerPath, configurator: TestFactoryConfigurator()) + .print("rootTranslatorPublisher") + RunLoop.current.run(until: Date(timeIntervalSinceNow: 2)) + + let expectOp1 = expectation(description: "Fourth Op. Op1") + let c4Op1 = rootTranslatorPublisher.flatMap { t in + return t.cloneWalkTo("fps") + } + .flatMap { $0.directoryFilesAndAttributes() } + .assertNoFailure() + //.print("first operation") + .sink { _ in + print("Fulfilled Op1") + expectOp1.fulfill() + } + let expectOp2 = expectation(description: "Fourth Op. Op2") + let c4Op2 = rootTranslatorPublisher.flatMap { t in + return t.cloneWalkTo("fps") + } + .flatMap { $0.directoryFilesAndAttributes() } + .assertNoFailure() + //.print("first operation") + .sink { _ in + print("Fulfilled Op2") + expectOp2.fulfill() + } + wait(for: [expectOp1, expectOp2], timeout: 2) + } + + func testTranslatorConnection() throws { + let providerPath = try BlinkFileProviderPath(TestFactoryConfigurator.LocalTestPath) + var connection = FilesTranslatorConnection(providerPath: providerPath, configurator: TestFactoryConfigurator()) + + //var rootTranslator: TranslatorPublisher = connection.rootTranslator + RunLoop.current.run(until: Date(timeIntervalSinceNow: 2)) + + print("First Operation") + let expectFirstOperation = expectation(description: "First Operation") + let c1 = connection.rootTranslator + .flatMap { $0.cloneWalkTo("fps") } + .flatMap { $0.directoryFilesAndAttributes() } + .assertNoFailure() + //.print("first operation") + .sink { attrs in + print("First Operation \(attrs.count) elements") + expectFirstOperation.fulfill() + } + + wait(for: [expectFirstOperation], timeout: 2) + print("==============================") + + print("Second Operation - Op1 be cancelled, Op2 succeed. Translator should not reset.") + let expectSecondOperationOp1 = expectation(description: "Second Operation - Op1") + let _ = connection.rootTranslator.flatMap { $0.cloneWalkTo("fps") } + .flatMap { $0.directoryFilesAndAttributes() } + .handleEvents(receiveCancel: { + print("Second Operation Op 1 cancelled") + expectSecondOperationOp1.fulfill() + }) + .assertNoFailure() + .sink { attrs in + //print("Second Operation \(attrs.count) elements") + } + wait(for: [expectSecondOperationOp1], timeout: 2) + + let expectSecondOperationOp2 = expectation(description: "Second Operation - Op2") + let c2 = connection.rootTranslator.flatMap { $0.cloneWalkTo("fps") } + .flatMap { $0.directoryFilesAndAttributes() } + .assertNoFailure() + .sink { attrs in + print("Second Operation \(attrs.count) elements") + expectSecondOperationOp2.fulfill() + } + wait(for: [expectSecondOperationOp2], timeout: 2) + print("==============================") + + print("Third Operation") + connection = FilesTranslatorConnection(providerPath: providerPath, configurator: TestFactoryConfigurator()) + RunLoop.current.run(until: Date(timeIntervalSinceNow: 2)) + + let expectCancel = expectation(description: "Cancel connection") + var cancel = connection.rootTranslator.flatMap { $0.cloneWalkTo("fps") } + .flatMap { $0.directoryFilesAndAttributes() } + .handleEvents(receiveCancel: { + print("Cancel received") + expectCancel.fulfill() + }) + .assertNoFailure() + .sink { _ in } + + RunLoop.current.run(until: Date(timeIntervalSinceNow: 0.1)) + cancel.cancel() + wait(for: [expectCancel]) + + let expectThirdOperation = expectation(description: "Third operation") + + let c3 = connection.rootTranslator.flatMap { $0.cloneWalkTo("fps") } + .flatMap { $0.directoryFilesAndAttributes() } + .assertNoFailure() + .sink { attrs in + print("Third Operation \(attrs.count) elements") + expectThirdOperation.fulfill() + } + wait(for: [expectThirdOperation], timeout: 2) + print("==============================") + + print("Fourth Operation - start connections at the same time") + connection = FilesTranslatorConnection(providerPath: providerPath, configurator: TestFactoryConfigurator()) + // Note doing this will not re-evaluate the status of the connection. This is the returned publisher. + // let rootTranslator = connection.rootTranslator + RunLoop.current.run(until: Date(timeIntervalSinceNow: 2)) + + let expectOpOne = expectation(description: "Multiple ops - op1") + let expectOpTwo = expectation(description: "Multiple ops - op2") + let _c4One = connection.rootTranslator.print("op1").flatMap { $0.cloneWalkTo("fps") } + .flatMap { + $0.directoryFilesAndAttributes() + } + .assertNoFailure() + .sink { attrs in + print("fulfill one") + expectOpOne.fulfill() + } + let _c4Two = connection.rootTranslator.print("op2") + .flatMap { $0.cloneWalkTo("fps") } + .flatMap { + $0.directoryFilesAndAttributes() + } + .assertNoFailure() + .sink { attrs in + print("fulfill two") + expectOpTwo.fulfill() + } + wait(for: [expectOpOne, expectOpTwo], timeout: 8) + print("==============================") + + } + + // Should add tests here for the SSH and SFTP Connections. + // Usage of translator in parallel. Reset connection. Reset SFTP channel, etc... + public func testProxyConnection() throws { + let providerPath = try BlinkFileProviderPath(TestProxyFactoryConfigurator.ProxyTestPath) + + var rootTranslatorPublisher: TranslatorPublisher? = try FileTranslatorFactory.rootTranslator(for: providerPath, configurator: TestProxyFactoryConfigurator()) + + print("First Operation") + let expectFirstOperation = expectation(description: "First Operation") + let c1 = rootTranslatorPublisher! + .flatMap { $0.directoryFilesAndAttributes() } + .assertNoFailure() + //.print("first operation") + .sink { attrs in + print("First Operation \(attrs.count) elements") + expectFirstOperation.fulfill() + } + + wait(for: [expectFirstOperation], timeout: 2) + + // Check the Proxy thread frees on exit. + rootTranslatorPublisher = nil + } +} + +class TestFactoryConfigurator: FileTranslatorFactory.Configurator { + static let LocalTestPath = "sftp:localhost:~/fps" + + func sshConfig(host title: String) throws -> (String, SSH.SSHClientConfig) { + let config = SSHClientConfig( + user: "nopass", + port: "2222" + ) + + return (title, config) + } +} + +class TestProxyFactoryConfigurator: FileTranslatorFactory.Configurator { + static let ProxyTestPath = "sftp:l:~/fps" + + struct TestProxyFactoryError: Error {} + + func sshConfig(host title: String) throws -> (String, SSH.SSHClientConfig) { + if title == "l" { + let config = SSHClientConfig( + user: "nopass", + port: "2222", + proxyJump: "local" + ) + return ("localhost", config) + } else if title == "local" { + let config = SSHClientConfig( + user: "nopass", + port: "2222" + ) + return ("localhost", config) + } + + throw TestProxyFactoryError() + } +} diff --git a/BlinkFileProviderTests/WorkingSetDatabaseTests.swift b/BlinkFileProviderTests/WorkingSetDatabaseTests.swift new file mode 100644 index 000000000..2e878ff30 --- /dev/null +++ b/BlinkFileProviderTests/WorkingSetDatabaseTests.swift @@ -0,0 +1,288 @@ +////////////////////////////////////////////////////////////////////////////////// +// +// B L I N K +// +// Copyright (C) 2016-2024 Blink Mobile Shell Project +// +// This file is part of Blink. +// +// Blink is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Blink is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Blink. If not, see . +// +// In addition, Blink is also subject to certain additional terms under +// GNU GPL version 3 section 7. +// +// You should have received a copy of these additional terms immediately +// following the terms and conditions of the GNU General Public License +// which accompanied the Blink Source Code. If not, see +// . +// +//////////////////////////////////////////////////////////////////////////////// + + +import XCTest +@testable import BlinkFileProvider + +final class WorkingSetTests: XCTestCase { + override func setUpWithError() throws { + BlinkLogging.handle(BlinkLoggingHandlers.print) + } + + // Unit tests for the DB itself. Not sure if we will keep them. + // The DB needs a specific behavior in specific cases, and it is easier to go through unit tests. + // updateItem + // updateItemsInContainer + // updateChangedItems + + func testWorkingSetDatabaseUpdateChanges() throws { + let location = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] + let db = try WorkingSetDatabase(path: location.appendingPathComponent("workingset.tests.db").path(), reset: true) + + print("Test 1 - load data") + let _ = try db.updateItemsInContainer(.rootContainer, items: [TestRows.file1, + TestRows.file2, + TestRows.container1, + TestRows.container1_file1, + TestRows.container1_container2, + TestRows.container1_container2_file1]) + var items = try db.items(in: .rootContainer) + XCTAssertTrue(items.count == 3) + items = try db.items(in: TestRows.container1.item) + XCTAssertTrue(items.count == 2) + items = try db.items(in: TestRows.container1_container2.item) + XCTAssertTrue(items.count == 1) + + print("Test 2 - no changes on root") + var deletedRows = try db.updateItemsInContainer(.rootContainer, items: [TestRows.file1, + TestRows.file2, + TestRows.container1]) + XCTAssertTrue(deletedRows.count == 0) + + print("Test 3 - Update single item") + try db.updateItem(TestRows.container1) + items = try db.items(in: TestRows.container1.item) + XCTAssertTrue(items.count == 2) + + print("Test 4 - Update Items in container, delete some content") + deletedRows = try db.updateItemsInContainer(TestRows.container1.blinkIdentifier(), items: [TestRows.container1_file1]) + XCTAssertTrue(deletedRows.count == 2) + XCTAssertThrowsError(try db.updateChangedItems(createRows: [TestRows.file1])) + _ = try db.updateChangedItems(updateRows: [TestRows.file1]) + items = try db.items(in: .rootContainer) + XCTAssertTrue(items.count == 3) + items = try db.items(in: TestRows.container1.item) + XCTAssertTrue(items.count == 1) + items = try db.items(in: TestRows.container1_container2.item) + XCTAssertTrue(items.count == 0) + + print("Test 5 - Update Changes, container deleted") + deletedRows = try db.updateChangedItems(updateRows: [TestRows.file2], deleteRows: [TestRows.container1, TestRows.file1]) + XCTAssertTrue(deletedRows.count == 3) + items = try db.items(in: .rootContainer) + XCTAssertTrue(items.count == 1) + items = try db.items(in: TestRows.container1.item) + XCTAssertTrue(items.count == 0) + items = try db.items(in: TestRows.container1_container2.item) + XCTAssertTrue(items.count == 0) + + } + + func testWorkingSetDatabaseReplaceItems() throws { + let location = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] + let db = try WorkingSetDatabase(path: location.appendingPathComponent("workingset.tests.db").path(), reset: true) + XCTAssertTrue((try! db.newestAnchor()) == 0) + + print("Test 1 - load data") + let _ = try db.updateItemsInContainer(.rootContainer, items: [TestRows.file1, + TestRows.container1, + TestRows.container1_container2, + TestRows.container1_container2_file1, + TestRows.container2]) + var items = try db.items(in: .rootContainer) + XCTAssertTrue(items.count == 3) + items = try db.items(in: TestRows.container1.item) + XCTAssertTrue(items.count == 1) + items = try db.items(in: TestRows.container1_container2.item) + XCTAssertTrue(items.count == 1) + + print("Test 3 - Update on same item") + let _ = try db.updateItem(TestRows.file1_alt) + items = try db.items(in: .rootContainer) + XCTAssertTrue(items.count == 3) + XCTAssertTrue(items.contains(where: { $0.name == TestRows.file1_alt.name })) + + print("Test 3.1 - Replacing item") + let _ = try db.updateItem(TestRows.file1) + let _ = try db.updateItem(TestRows.file1_replacement) + items = try db.items(in: .rootContainer) + XCTAssertTrue(items.count == 3) + XCTAssertTrue(items.contains(where: { $0.name == TestRows.file1.name && + $0.item == TestRows.file1_replacement.item })) + + print("Test 4 - Update folder") + var replacedItems = try db.updateItem(TestRows.container1_alt) + items = try db.items(in: TestRows.container1_alt.item) + XCTAssertTrue(items.count == 1) + XCTAssertTrue(replacedItems.count == 0) + XCTAssertTrue(items[0].containerPath.contains(TestRows.container1_alt.name)) + items = try db.items(in: TestRows.container1_container2.item) + XCTAssertTrue(items.count == 1) + XCTAssertTrue(items[0].containerPath.contains( + (TestRows.container1_alt.name as NSString) + .appendingPathComponent(TestRows.container1_container2.name) + )) + let _ = try db.updateItem(TestRows.container1) + + print("Test 4.1 - Replace folder and move down") + replacedItems = try db.updateItem(TestRows.container1_container2_on_root) + // Maybe tests would be easier if we tracked the IDs separately. As the intention is for the item to be the same. + items = try db.items(in: TestRows.container1_container2.item) + XCTAssertTrue(replacedItems.count == 1) + XCTAssertTrue(items.count == 1) + // Because it is replaced, the container name should be the same + XCTAssertTrue(items[0].containerPath == replacedItems[0].name) + + // Move back up + let _ = try db.updateItem(TestRows.container1_container2) + items = try db.items(in: TestRows.container1_container2.item) + XCTAssertTrue(items.count == 1) + XCTAssertTrue(items[0].containerPath.contains(TestRows.container1_container2.containerPath)) + } + + func testConcurrentUpdates() throws { + let location = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] + let db = try WorkingSetDatabase(path: location.appendingPathComponent("workingset.tests.db").path(), reset: true) + let updateQueue = DispatchQueue(label: "ConcurrentQueue", attributes: .concurrent) + let expectUpdates = XCTestExpectation(description: "Concurrent Updates") + + let items = [TestRows.file1, + TestRows.file2, + TestRows.container1, + TestRows.container1_file1, + TestRows.container1_container2, + TestRows.container1_container2_file1] + + for item in items { + updateQueue.async { + try! db.updateItem(item) + } + } + + // At the same time, check items in containers as we place them. + // Release the expectation once we can read them. + updateQueue.async { + var containers = [TestRows.container1, TestRows.container1_container2] + while !containers.isEmpty { + for idx in containers.indices.reversed() { + let items = try! db.items(in: containers[idx].item) + if items.count > 0 { + containers.remove(at: idx) + } + } + } + expectUpdates.fulfill() + } + + wait(for: [expectUpdates], timeout: 4) + } +} + +enum TestRows { + static let version1 = NSFileProviderItemVersion(contentVersion: "asdf".data(using: .utf8)!, metadataVersion: "asdf".data(using: .utf8)!) + static let file1 = ItemRow(item: NSFileProviderItemIdentifier.shortUUID(), + name: "file1", + container: .rootContainer, + containerPath: "", + version: version1, + isContainer: false, + anchor: 1) + static let file1_alt = ItemRow(item: file1.item, + name: "file1_alt", + container: .rootContainer, + containerPath: "", + version: version1, + isContainer: false, + anchor: 1) + static let file1_replacement = ItemRow(item: NSFileProviderItemIdentifier.shortUUID(), + name: "file1", + container: .rootContainer, + containerPath: "", + version: version1, + isContainer: false, + anchor: 1) + + static let file2 = ItemRow(item: NSFileProviderItemIdentifier.shortUUID(), + name: "file2", + container: .rootContainer, + containerPath: "", + version: version1, + isContainer: false, + anchor: 1) + static let container1 = ItemRow(item: NSFileProviderItemIdentifier.shortUUID(), + name: "container1", + container: .rootContainer, + containerPath: "", + version: version1, + isContainer: true, + anchor: 1) + static let container1_alt = ItemRow(item: container1.item, + name: "container1_alt", + container: .rootContainer, + containerPath: "", + version: version1, + isContainer: true, + anchor: 1) + static let container2 = ItemRow(item: NSFileProviderItemIdentifier.shortUUID(), + name: "container2", + container: .rootContainer, + containerPath: "", + version: version1, + isContainer: true, + anchor: 1) + static let container2_file1 = ItemRow(item: NSFileProviderItemIdentifier.shortUUID(), + name: "file1", + container: container2.item, + containerPath: "container2", + version: version1, + isContainer: false, + anchor: 1) + static let container1_file1 = ItemRow(item: NSFileProviderItemIdentifier.shortUUID(), + name: "file1", + container: container1.item, + containerPath: "container1", + version: version1, + isContainer: false, + anchor: 1) + static let container1_container2 = ItemRow(item: NSFileProviderItemIdentifier.shortUUID(), + name: "container2", + container: container1.item, + containerPath: "container1", + version: version1, + isContainer: true, + anchor: 1) + static let container1_container2_on_root = ItemRow(item: container1_container2.item, + name: container1_container2.name, + container: .rootContainer, + containerPath: "", + version: version1, + isContainer: true, + anchor: 1) + static let container1_container2_file1 = ItemRow(item: NSFileProviderItemIdentifier.shortUUID(), + name: "file1", + container: container1_container2.item, + containerPath: "container1/container2", + version: version1, + isContainer: false, + anchor: 1) + +} diff --git a/BlinkFiles/BlinkFiles+Extensions.swift b/BlinkFiles/BlinkFiles+Extensions.swift index 3ff962f3e..d4c43c169 100644 --- a/BlinkFiles/BlinkFiles+Extensions.swift +++ b/BlinkFiles/BlinkFiles+Extensions.swift @@ -32,6 +32,11 @@ import Foundation import Combine +public struct BlinkFilesError: Error, LocalizedError { + let errorDescription: String + let originalError: Error +} + public extension Translator { func cloneWalkTo(_ path: String) -> AnyPublisher { let t = self.clone() @@ -64,11 +69,11 @@ extension Translator { return name } return nil - }.flatMap { - sourceRootTranslator!.cloneWalkTo($0) + }.flatMap { name in + sourceRootTranslator!.cloneWalkTo(name).mapError { err in BlinkFilesError(errorDescription: "Could not walk to \(name)", originalError: err)} }.eraseToAnyPublisher() } - + fileprivate func wildcard(_ string: String, pattern: String) -> Bool { let pred = NSPredicate(format: "self LIKE %@", pattern) return !NSArray(object: string).filtered(using: pred).isEmpty @@ -88,7 +93,14 @@ extension Translator { } return cloneWalkTo(name) - .flatMap { $0.stat() } + .flatMap { $0.stat() + .map { attrs in + // Resolve it but make sure the name is still the symlink, otherwise it will be the destination. + var attrs = attrs + attrs[.name] = name + return attrs + } + } .catch { _ in Just(attrs) } .eraseToAnyPublisher() }.map { $0 } @@ -96,4 +108,45 @@ extension Translator { .eraseToAnyPublisher() }.eraseToAnyPublisher() } + + public func directoryFilesAndAttributesWithTargetLinks() -> AnyPublisher<[FileAttributes], Error> { + directoryFilesAndAttributes() + .flatMap { filesAttributes -> AnyPublisher<[FileAttributes], Never> in + filesAttributes.publisher + .flatMap { attrs -> AnyPublisher in + guard let type = attrs[.type] as? FileAttributeType, + let name = attrs[.name] as? String, + type == .typeSymbolicLink else { + return .just(attrs) + } + + return cloneWalkTo(name) + .flatMap { + $0.stat() + .map { targetAttrs in + var attrs = attrs + attrs[.symbolicLinkTargetInfo] = targetAttrs + return attrs + } + } + .catch { _ in Just(attrs) } + .eraseToAnyPublisher() + }.map { $0 } + .collect() + .eraseToAnyPublisher() + }.eraseToAnyPublisher() + } + + public func mkdir(name: String) -> AnyPublisher { + mkdir(name: name, mode: S_IRWXU | S_IRWXG | S_IRWXO) + } + + public func mkPath(path: String) -> AnyPublisher { + cloneWalkTo(path) + .catch { _ in + let name = (path as NSString).lastPathComponent + let parentPath = (path as NSString).deletingLastPathComponent + return mkPath(path: parentPath).flatMap { $0.mkdir(name: name ) }.eraseToAnyPublisher() + }.eraseToAnyPublisher() + } } diff --git a/BlinkFiles/BlinkFiles.swift b/BlinkFiles/BlinkFiles.swift index e29edf130..a4e9fd367 100644 --- a/BlinkFiles/BlinkFiles.swift +++ b/BlinkFiles/BlinkFiles.swift @@ -41,6 +41,7 @@ public typealias BlinkFilesAttributeKey = FileAttributeKey // to work while still respecting the proper interface everywhere. public extension BlinkFilesAttributeKey { static let name: FileAttributeKey = FileAttributeKey("fileName") + static let symbolicLinkTargetInfo: FileAttributeKey = FileAttributeKey("symbolicLinkTargetInfo") } public typealias FileAttributes = [BlinkFilesAttributeKey: Any] @@ -56,15 +57,18 @@ public protocol Translator: CopierFrom { // The walk offers a way to traverse the remote filesystem, without dealing with internal formats. // It is responsible to extend paths and define objects along the way. func walkTo(_ path: String) -> AnyPublisher + + // Join merges the path without resolving it. + func join(_ path: String) throws -> Translator // Equivalent to a directory read, it returns all the elements and attrs from stating the containing objects func directoryFilesAndAttributes() -> AnyPublisher<[FileAttributes], Error> // Creates the file name at the current walked path with the given permissions. // Default mode = S_IRWXU - func create(name: String, flags: Int32, mode: mode_t) -> AnyPublisher + func create(name: String, mode: mode_t) -> AnyPublisher // Creates the directory name at the current walked path with the given permissions. - // Default mode = S_IRWUX | S_IRWXG | S_IRWXO + // Default mode = S_IRWXU | S_IRWXG | S_IRWXO func mkdir(name: String, mode: mode_t) -> AnyPublisher // Opens a Stream to the object diff --git a/BlinkFiles/CopyFiles.swift b/BlinkFiles/CopyFiles.swift index 7930c3e64..f3e683e47 100644 --- a/BlinkFiles/CopyFiles.swift +++ b/BlinkFiles/CopyFiles.swift @@ -91,7 +91,20 @@ extension Translator { copyElement(from: t, args: args) }.eraseToAnyPublisher() } - + + public func copy(from t: Translator, newName: String, args: CopyArguments = CopyArguments()) -> CopyProgressInfoPublisher { + print("Copying as \(newName)") + return self.cloneWalkTo(newName) + .tryCatch { _ -> AnyPublisher in + return self.create(name: newName, mode: S_IRWXU) + .flatMap { $0.close() } + .flatMap { _ in self.cloneWalkTo(newName) } + .eraseToAnyPublisher() + } + .flatMap { $0.copyElement(from: t, args: args) } + .eraseToAnyPublisher() + } + // Self can be a File or a directory. fileprivate func copyElement(from t: Translator, args: CopyArguments) -> CopyProgressInfoPublisher { return Just(t) @@ -201,10 +214,10 @@ extension Translator { let fullFile: String let file: AnyPublisher - // If we are in a directory, we create the file, otherwise we open truncated. + // If we are a directory, we create the file. If we are a file, we open truncated. if self.isDirectory { fullFile = (self.current as NSString).appendingPathComponent(name) - file = self.create(name: name, flags: O_WRONLY, mode: S_IRWXU) + file = self.create(name: name, mode: S_IRWXU) } else { fullFile = self.current file = self.open(flags: O_WRONLY | O_TRUNC) diff --git a/BlinkFiles/LocalFiles.swift b/BlinkFiles/LocalFiles.swift index d663311a9..041d54830 100644 --- a/BlinkFiles/LocalFiles.swift +++ b/BlinkFiles/LocalFiles.swift @@ -110,6 +110,17 @@ public class Local : Translator { }.eraseToAnyPublisher() } + public func join(_ path: String) throws -> Translator { + var absPath = (path as NSString).standardizingPath + if !path.starts(with: "/") { + absPath = (current as NSString).appendingPathComponent(absPath) + } + self.fileType = .typeUnknown + self.current = absPath + + return self + } + func fileAttributes(atPath path: String) -> AnyPublisher { return fileManager().tryMap { fm -> FileAttributes in let nsPath = (path as NSString) @@ -134,8 +145,7 @@ public class Local : Translator { }.eraseToAnyPublisher() } - // // TODO Change permissions to more generic open options - public func create(name: String, flags: Int32, mode: mode_t = S_IRWXU) -> AnyPublisher { + public func create(name: String, mode: mode_t = S_IRWXU) -> AnyPublisher { if fileType != .typeDirectory { return fail(msg: "Not a directory.") } @@ -148,7 +158,7 @@ public class Local : Translator { throw LocalFileError(msg: "Could not create file.") } - return try LocalFile(at: absPath, flags: flags) + return try LocalFile(at: absPath, flags: O_WRONLY|O_CREAT|O_TRUNC) }.eraseToAnyPublisher() } diff --git a/BlinkFilesTests/LocalFilesTests.swift b/BlinkFilesTests/LocalFilesTests.swift index 149ea334a..c87b59964 100644 --- a/BlinkFilesTests/LocalFilesTests.swift +++ b/BlinkFilesTests/LocalFilesTests.swift @@ -176,7 +176,7 @@ class LocalFilesTests: XCTestCase { f.walkTo("/Users/carlos/Xcode_12.0.1.xip") .flatMap { $0.open(flags: O_RDONLY) } .flatMap { srcFile -> AnyPublisher in - return dst.create(name: "Docker-copy.dmg", flags: O_WRONLY, mode: 0o644) + return dst.create(name: "Docker-copy.dmg", mode: 0o644) .flatMap { dstFile in return (srcFile as! WriterTo).writeTo(dstFile) }.eraseToAnyPublisher() diff --git a/BlinkLogging/BlinkLogging.swift b/BlinkLogging/BlinkLogging.swift index f1799cf48..0e204e9fc 100644 --- a/BlinkLogging/BlinkLogging.swift +++ b/BlinkLogging/BlinkLogging.swift @@ -35,12 +35,30 @@ import Foundation public class BlinkLogging { - public typealias LogHandlerFactory = ((Publishers.Share>) throws -> AnyCancellable) + public typealias LogHandlerParameters = (Publishers.Share>) + public typealias LogHandlerFactory = ((LogHandlerParameters) throws -> AnyCancellable) fileprivate static var handlers = [LogHandlerFactory]() public static func handle(_ handler: @escaping LogHandlerFactory) { self.handlers.append(handler) } + + public static func reset() { + self.handlers = [] + } +} + +enum BlinkLoggingHandlers { + static func print(logPublisher: BlinkLogging.LogHandlerParameters) -> AnyCancellable { + logPublisher.filter(logLevel: .debug) + .format { [ + "[\(Date().formatted(.iso8601))]", + "[\($0[.logLevel] ?? BlinkLogLevel.log)]", + $0[.component] as? String ?? "global", + $0[.message] as? String ?? "" + ].joined(separator: " : ") } + .sinkToOutput() + } } // BlinkLogging.handler { $0.map {}.sinkTo } @@ -56,7 +74,7 @@ public struct BlinkLogKeys: Hashable { } } -public enum BlinkLogLevel: Int, Comparable { +public enum BlinkLogLevel: Int, Comparable, CustomStringConvertible { case trace case debug case info @@ -69,6 +87,18 @@ public enum BlinkLogLevel: Int, Comparable { public static func < (lhs: BlinkLogLevel, rhs: BlinkLogLevel) -> Bool { lhs.rawValue < rhs.rawValue } + + public var description: String { + switch self { + case .trace: "TRACE" + case .debug: "DEBUG" + case .info: "INFO" + case .warn: "WARN" + case .error: "ERROR" + case .fatal: "FATAL" + case .log: "LOG" + } + } } class BlinkLogger: Subject { diff --git a/BlinkTests/MoshBootstrapTests.swift b/BlinkTests/MoshBootstrapTests.swift new file mode 100644 index 000000000..14cc8f35f --- /dev/null +++ b/BlinkTests/MoshBootstrapTests.swift @@ -0,0 +1,114 @@ +////////////////////////////////////////////////////////////////////////////////// +// +// B L I N K +// +// Copyright (C) 2016-2023 Blink Mobile Shell Project +// +// This file is part of Blink. +// +// Blink is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Blink is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Blink. If not, see . +// +// In addition, Blink is also subject to certain additional terms under +// GNU GPL version 3 section 7. +// +// You should have received a copy of these additional terms immediately +// following the terms and conditions of the GNU General Public License +// which accompanied the Blink Source Code. If not, see +// . +// +//////////////////////////////////////////////////////////////////////////////// + +import Combine +import XCTest + +import SSH + +@testable import Blink + +final class MoshBootstrapTests: XCTestCase { + var cancellableBag: Set = [] + + func testMoshBootstrap() throws { + print("connecting...") + + let expectConn = self.expectation(description: "Connection established") + + var connection: SSHClient! + SSHClient.dial(SSHClientConfig.testHost, with: .testConfig) + .sink( + receiveCompletion: { _ in }, + receiveValue: { conn in + connection = conn + expectConn.fulfill() + }).store(in: &cancellableBag) + + wait(for: [expectConn], timeout: 5) + + print("connected") + + let expectBootstrap = self.expectation(description: "Mosh bootstrapped") + let logger = MoshLogger(output: OutputStream(file: stdout)) + + InstallStaticMosh(promptUser: false, logger: logger) + .start(on: connection) + .sink( + receiveCompletion: { _ in }, + receiveValue: { moshServerPath in + print("Mosh server path at: \(moshServerPath)") + expectBootstrap.fulfill() + } + ).store(in: &cancellableBag) + + wait(for: [expectBootstrap], timeout: 30) + } + + func testMoshDownloadBinaries() throws { + let logger = MoshLogger(output: OutputStream(file: stdout), logLevel: .info) + let moshBootstrap = InstallStaticMosh(promptUser: false, logger: logger) + + moshBootstrap.getMoshServerBinary(platform: .Darwin, architecture: .X86_64) + .assertNoFailure() + .sink(test: self) + + moshBootstrap.getMoshServerBinary(platform: .Darwin, architecture: .Arm64) + .assertNoFailure() + .sink(test: self) + + moshBootstrap.getMoshServerBinary(platform: .Linux, architecture: .Amd64) + .assertNoFailure() + .sink(test: self) + + moshBootstrap.getMoshServerBinary(platform: .Linux, architecture: .Arm64) + .assertNoFailure() + .sink(test: self) + + moshBootstrap.getMoshServerBinary(platform: .Linux, architecture: .Armv7) + .assertNoFailure() + .sink(test: self) + } +} + +// TODO Test getting parameters from expected Mosh output. Important in case we find weird cases, but not critical. +// TODO - We could test the Bootstrap request flow, separating it to a different object. Complicated and not sure what extra insight we would get from it. +// TODO Test configurations from .ssh/config + parameters. How? This will have to go to the QA instructions. + +extension SSHClientConfig { + static let testHost = "localhost" + static let testConfig = SSHClientConfig( + user: "asdf", + port: "22", + authMethods: [AuthPassword(with: "")], + loggingVerbosity: .debug + ) +} diff --git a/Blink/WhatsNew/WhatsNewSceneDelegate.swift b/BlinkTests/SSHCommandTest.swift similarity index 53% rename from Blink/WhatsNew/WhatsNewSceneDelegate.swift rename to BlinkTests/SSHCommandTest.swift index c0fd8bcf1..9ff37d0e8 100644 --- a/Blink/WhatsNew/WhatsNewSceneDelegate.swift +++ b/BlinkTests/SSHCommandTest.swift @@ -2,7 +2,7 @@ // // B L I N K // -// Copyright (C) 2016-2019 Blink Mobile Shell Project +// Copyright (C) 2016-2023 Blink Mobile Shell Project // // This file is part of Blink. // @@ -30,37 +30,21 @@ //////////////////////////////////////////////////////////////////////////////// -import Foundation -import UIKit -import SwiftUI - -class FocusableVC: UIHostingController { - override var canBecomeFirstResponder: Bool { true } - override var prefersStatusBarHidden: Bool { false } - override var preferredStatusBarStyle: UIStatusBarStyle { .default } - public override var prefersHomeIndicatorAutoHidden: Bool { false } -} +import XCTest +@testable import Blink -class WhatsNewSceneDelegate: UIResponder, UIWindowSceneDelegate { - var window: UIWindow? = nil - - func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { - - guard let windowScene = scene as? UIWindowScene else { - return - } - - let window = UIWindow(windowScene: windowScene) - self.window = window - - let root = FocusableVC(rootView: GridView(rowsProvider: RowsViewModel(baseURL: XCConfig.infoPlistWhatsNewURL()), ipad: true)) - - // Reset version when opening. - WhatsNewInfo.setNewVersion() - - window.rootViewController = root - window.isHidden = false +final class SSHCommandTest: XCTestCase { + func testSSHCommandParams() throws { + var cmd: SSHCommand + XCTAssertThrowsError(try SSHCommand.parse(["-t", "-T", "user@host"])) + XCTAssertThrowsError(try SSHCommand.parse(["-o", "ForwardAgent", "yes", "user@host"])) + cmd = try SSHCommand.parse(["-L", "11:forward:00", "-o", "ForwardAgent=yes", "user@host","-vv", "-p", "2222", "-L", "forward", "--", "cat", "-v", "hello"]) + XCTAssertTrue(cmd.customPort == 2222) + XCTAssertTrue(cmd.command == ["cat", "-v", "hello"]) + XCTAssertTrue(cmd.localForward.count == 2) + // Resolved at the SSH Config level + XCTAssertTrue(cmd.agentForward == false) + XCTAssertTrue(cmd.verbosity == 2) } - } diff --git a/CHANGELOG.md b/CHANGELOG.old.md similarity index 100% rename from CHANGELOG.md rename to CHANGELOG.old.md diff --git a/KB/Native/Model/KeyBindingAction.swift b/KB/Native/Model/KeyBindingAction.swift index 0900287ce..f0ce527c8 100644 --- a/KB/Native/Model/KeyBindingAction.swift +++ b/KB/Native/Model/KeyBindingAction.swift @@ -110,14 +110,14 @@ enum Command: String, Codable, CaseIterable { } enum KeyBindingAction: Codable, Identifiable { - case hex(String, comment: String?) + case hex(String, stringInput: String?, comment: String?) case press(KeyCode, mods: Int) case command(Command) case none var id: String { switch self { - case .hex(let str, _): return "hex-\(str)" + case .hex(let str, _, _): return "hex-\(str)" case .press(let keyCode, let mods): return "press-\(keyCode.id)-\(mods)" case .command(let cmd): return "cmd-\(cmd)" case .none: return "none" @@ -133,12 +133,20 @@ enum KeyBindingAction: Codable, Identifiable { var isCustomHEX: Bool { switch self { - case .hex(_, comment: let comment): + case .hex(_, _, comment: let comment): return comment == nil default: return false } } + var isHexStringInput: Bool { + switch self { + case .hex(_, stringInput: let stringInput, comment: let comment): + return comment == nil && stringInput != nil + default: return false + } + } + static func press(_ keyCode: KeyCode, _ mods: UIKeyModifierFlags) -> KeyBindingAction { KeyBindingAction.press(keyCode, mods: mods.rawValue) } @@ -164,15 +172,15 @@ enum KeyBindingAction: Codable, Identifiable { .press(.f10, []), .press(.f11, []), .press(.f12, []), - .hex("03", comment: "Press ^C"), - .hex("16", comment: "Press ^V"), - .hex("3C", comment: "Press <"), - .hex("3E", comment: "Press >"), - .hex("A7", comment: "Press §"), - .hex("B1", comment: "Press ±"), - .hex("7E", comment: "Press ~"), - .hex("7C", comment: "Press |"), - .hex("5C", comment: "Press \\"), + .hex("03", stringInput: nil, comment: "Press ^C"), + .hex("16", stringInput: nil, comment: "Press ^V"), + .hex("3C", stringInput: nil, comment: "Press <"), + .hex("3E", stringInput: nil, comment: "Press >"), + .hex("A7", stringInput: nil, comment: "Press §"), + .hex("B1", stringInput: nil, comment: "Press ±"), + .hex("7E", stringInput: nil, comment: "Press ~"), + .hex("7C", stringInput: nil, comment: "Press |"), + .hex("5C", stringInput: nil, comment: "Press \\"), .press(.w, [.command]), .press(.t, [.command]), // .press(.left, [.shift, .command]), @@ -186,8 +194,8 @@ enum KeyBindingAction: Codable, Identifiable { var title: String { switch self { - case .hex(let str, comment: let comment): - return comment ?? "Hex: \(str)" + case .hex(let str, stringInput: let stringInput, comment: let comment): + return comment ?? (stringInput != nil ? "String: \(stringInput!)" : "Hex: \(str)") case .press(let keyCode, let mods): var sym = UIKeyModifierFlags(rawValue: mods).toSymbols() sym += keyCode.symbol @@ -199,17 +207,17 @@ enum KeyBindingAction: Codable, Identifiable { var titleWithoutValue: String { switch self { - case .hex(_, comment: let comment): - return comment ?? "Send Hex Code" + case .hex(_, stringInput: let stringInput, comment: let comment): + return comment ?? (stringInput != nil ? "Send Input" : "Send Hex Code") default: return title } } - var hexValue: String { + var hexValues : (String, String?) { switch self { - case .hex(let str, comment: _): - return str - default: return "" + case .hex(let str, stringInput: let stringInput, comment: _): + return (str, stringInput) + default: return ("", nil) } } @@ -219,6 +227,7 @@ enum KeyBindingAction: Codable, Identifiable { case type case hex case value + case stringInput case key case press case mods @@ -230,9 +239,10 @@ enum KeyBindingAction: Codable, Identifiable { func encode(to encoder: Encoder) throws { var c = encoder.container(keyedBy: Keys.self) switch self { - case .hex(let str, comment: let comment): + case .hex(let str, stringInput: let stringInput, comment: let comment): try c.encode(Keys.hex.stringValue, forKey: .type) try c.encode(str, forKey: .value) + try c.encodeIfPresent(stringInput, forKey: .stringInput) try c.encodeIfPresent(comment, forKey: .comment) case .press(let keyCode, let mods): try c.encode(Keys.press.stringValue, forKey: .type) @@ -254,8 +264,9 @@ enum KeyBindingAction: Codable, Identifiable { switch k { case .hex: let hex = try c.decode(String.self, forKey: .value) + let stringInput = try c.decodeIfPresent(String.self, forKey: .stringInput) let comment = try c.decodeIfPresent(String.self, forKey: .comment) - self = .hex(hex, comment: comment) + self = .hex(hex, stringInput: stringInput, comment: comment) case .press: let keyCode = try c.decode(KeyCode.self, forKey: .key) let mods = try c.decode(Int.self, forKey: .mods) diff --git a/KB/Native/Model/KeyShortcut.swift b/KB/Native/Model/KeyShortcut.swift index f4b516368..604b410cb 100644 --- a/KB/Native/Model/KeyShortcut.swift +++ b/KB/Native/Model/KeyShortcut.swift @@ -76,8 +76,40 @@ class KeyShortcut: ObservableObject, Codable, Identifiable { res += KeyCode.up.symbol case UIKeyCommand.inputDownArrow: res += KeyCode.down.symbol + case UIKeyCommand.inputHome: + res += KeyCode.home.symbol + case UIKeyCommand.inputEnd: + res += KeyCode.end.symbol + case UIKeyCommand.inputPageUp: + res += KeyCode.pageUp.symbol + case UIKeyCommand.inputPageDown: + res += KeyCode.pageDown.symbol case UIKeyCommand.inputEscape: res += KeyCode.escape.symbol + case UIKeyCommand.f1: + res += KeyCode.f1.symbol + case UIKeyCommand.f2: + res += KeyCode.f2.symbol + case UIKeyCommand.f3: + res += KeyCode.f3.symbol + case UIKeyCommand.f4: + res += KeyCode.f4.symbol + case UIKeyCommand.f5: + res += KeyCode.f5.symbol + case UIKeyCommand.f6: + res += KeyCode.f6.symbol + case UIKeyCommand.f7: + res += KeyCode.f7.symbol + case UIKeyCommand.f8: + res += KeyCode.f8.symbol + case UIKeyCommand.f9: + res += KeyCode.f9.symbol + case UIKeyCommand.f10: + res += KeyCode.f10.symbol + case UIKeyCommand.f11: + res += KeyCode.f11.symbol + case UIKeyCommand.f12: + res += KeyCode.f12.symbol case " ": res += KeyCode.space.symbol case "\r": diff --git a/KB/Native/Views/KBWebViewBase.m b/KB/Native/Views/KBWebViewBase.m index 785cda74a..b9cce3f2e 100644 --- a/KB/Native/Views/KBWebViewBase.m +++ b/KB/Native/Views/KBWebViewBase.m @@ -135,7 +135,11 @@ - (void)_blink_updateTextInputTraits:(id )traits { traits.autocorrectionType = UITextAutocorrectionTypeNo; traits.autocapitalizationType = UITextAutocapitalizationTypeNone; traits.spellCheckingType = UITextSpellCheckingTypeNo; - traits.smartInsertDeleteType = UITextSmartInsertDeleteTypeNo; + // NOTE: Fixes crash introduced on iOS 17.4. This function is called multiple times, and for + // some reason, on one of them the selector will not exist and it will crash the app on start. Issue #1945 + if ([traits respondsToSelector:@selector(setSmartInsertDeleteType:)]) { + traits.smartInsertDeleteType = UITextSmartInsertDeleteTypeNo; + } } - (KeyCommand *)_modifiersCommand:(UIKeyModifierFlags) flags { diff --git a/KB/Native/Views/KeyCaptureView.swift b/KB/Native/Views/KeyCaptureView.swift index 2913410fa..0f1e262a2 100644 --- a/KB/Native/Views/KeyCaptureView.swift +++ b/KB/Native/Views/KeyCaptureView.swift @@ -46,7 +46,24 @@ class CaptureController: UIViewController { UIKeyCommand.inputDownArrow, UIKeyCommand.inputLeftArrow, UIKeyCommand.inputRightArrow, - UIKeyCommand.inputEscape + UIKeyCommand.inputEscape, + UIKeyCommand.inputHome, + UIKeyCommand.inputEnd, + UIKeyCommand.inputPageUp, + UIKeyCommand.inputPageDown, + // Function keys up to F12 as reported by shortcuts. + UIKeyCommand.f1, + UIKeyCommand.f2, + UIKeyCommand.f3, + UIKeyCommand.f4, + UIKeyCommand.f5, + UIKeyCommand.f6, + UIKeyCommand.f7, + UIKeyCommand.f8, + UIKeyCommand.f9, + UIKeyCommand.f10, + UIKeyCommand.f11, + UIKeyCommand.f12 ] let modifiers: [UIKeyModifierFlags] = [.shift, .control, .alternate, .command] var mods: Set = [0] diff --git a/KB/Native/Views/ShortcutsConfigView.swift b/KB/Native/Views/ShortcutsConfigView.swift index dad471981..58fe2fb1b 100644 --- a/KB/Native/Views/ShortcutsConfigView.swift +++ b/KB/Native/Views/ShortcutsConfigView.swift @@ -51,6 +51,7 @@ struct ActionsList: View { } else { Section(header: Text("Send")) { self._rowHex(action: self.action) + self._rowCustomInput(action: self.action) } Section(header: Text("Press")) { ForEach(pressList, id: \.id) { ka in @@ -66,7 +67,8 @@ struct ActionsList: View { private func _rowHex(action: KeyBindingAction) -> some View { var checked = false var value = "" - if case .hex(let val, let comment) = action, comment == nil { + + if case .hex(let val, let input, let comment) = action, input == nil, comment == nil { checked = true value = val } @@ -76,7 +78,30 @@ struct ActionsList: View { Checkmark(checked: checked) }.overlay( Button(action: { - self.action = .hex(value, comment: nil) + self.action = .hex(value, stringInput: nil, comment: nil) + self.updatedAt = Date() + }, label: { EmptyView() } + ) + ) + } + + private func _rowCustomInput(action: KeyBindingAction) -> some View { + var checked = false + var value = "" + var stringInput = "" + + if case .hex(let val, let input, let comment) = action, input != nil, comment == nil { + checked = true + value = val + stringInput = input! + } + return HStack { + Text("Custom String") + Spacer() + Checkmark(checked: checked) + }.overlay( + Button(action: { + self.action = .hex(value, stringInput: stringInput, comment: nil) self.updatedAt = Date() }, label: { EmptyView() } ) @@ -132,22 +157,77 @@ class HexFormatter: Formatter { .prefix(1000) ) } + + func stringToHexString(_ input: String) -> String { + var result = "" + var currentIndex = input.startIndex + + while currentIndex < input.endIndex { + let currentCharacter = input[currentIndex] + + if currentCharacter == "\\" && input.index(currentIndex, offsetBy: 1) < input.endIndex && input[input.index(currentIndex, offsetBy: 1)] == "x" { + // Skip "\x" + currentIndex = input.index(currentIndex, offsetBy: 2) + var hexSubstring = "" + for _ in 0..<2 { + if currentIndex < input.endIndex, "0123456789ABCDEFabcdef".contains(input[currentIndex]) { + hexSubstring.append(input[currentIndex]) + currentIndex = input.index(after: currentIndex) + } else { + break + } + } + // Ensure is double digit. + if hexSubstring.count == 1 { + hexSubstring = "0" + hexSubstring + } + result.append(hexSubstring) + } else { + let hexValue = String(format: "%02X", currentCharacter.unicodeScalars.first?.value ?? 0) + result.append(hexValue) + currentIndex = input.index(after: currentIndex) + } + } + + return result + } } struct HexEditorView: View { @ObservedObject var shortcut: KeyShortcut - @State var value: String = "" + @State var input: String = "" + var value: String { shortcut.action.hexValues.0 } + var stringInput: String? { shortcut.action.hexValues.1 } private let _formatter = HexFormatter() var body: some View { - TextField("HEX", text: $value, onEditingChanged: { _ in - // Whenever the view is first shown, enter pressed, tap back on Navigation Link & TextField selected - // Update the HEX code using the HexFormatter to only accept valid HEX encoded Strings - value = _formatter.hexString(str: value) - shortcut.action = .hex(value, comment: nil) - }) - .disableAutocorrection(true) - .keyboardType(.asciiCapable) + _editor() + .onAppear { + if let stringInput = stringInput { + self.input = stringInput + } else { + self.input = value + } + } + .disableAutocorrection(true) + .keyboardType(.asciiCapable) + } + + private func _editor() -> some View { + if self.stringInput != nil { + return TextField("Custom String", text: $input, + onEditingChanged: { _ in + let value = _formatter.stringToHexString(input) + shortcut.action = .hex(value, stringInput: input, comment: nil) + }) + } else { + return TextField("HEX", text: $input, onEditingChanged: { _ in + // Whenever the view is first shown, enter pressed, tap back on Navigation Link & TextField selected + // Update the HEX code using the HexFormatter to only accept valid HEX encoded Strings + let value = _formatter.hexString(str: input) + shortcut.action = .hex(value, stringInput: nil, comment: nil) + }) + } } } @@ -171,15 +251,16 @@ struct ShortcutConfigView: View { } Section( header: Text("Action"), - footer: Text(self.shortcut.action.isCustomHEX ? "Use hex encoded sequence" : "")) + footer: Text(self.shortcut.action.isCustomHEX ? (self.shortcut.action.isHexStringInput ? + "Use string sequence, with \\x for escape characters." : + "Use hex encoded sequence") : "")) { DefaultRow(title: shortcut.action.titleWithoutValue) { ActionsList(action: self.$shortcut.action, commandsMode: self.commandsMode) } if self.shortcut.action.isCustomHEX { HexEditorView( - shortcut: self.shortcut, - value: self.shortcut.action.hexValue + shortcut: self.shortcut ) } } diff --git a/Media.xcassets/AppIcon.appiconset/Contents.json b/Media.xcassets/AppIcon.appiconset/Contents.json index e23c9e7cc..6daf1cd1b 100644 --- a/Media.xcassets/AppIcon.appiconset/Contents.json +++ b/Media.xcassets/AppIcon.appiconset/Contents.json @@ -1,122 +1,509 @@ { "images" : [ { - "size" : "20x20", - "idiom" : "iphone", "filename" : "iPhone-Notification-20pt@2x.png", - "scale" : "2x" + "idiom" : "universal", + "platform" : "ios", + "scale" : "2x", + "size" : "20x20" }, { - "size" : "20x20", - "idiom" : "iphone", "filename" : "iPhone-Notification-20pt@3x.png", - "scale" : "3x" + "idiom" : "universal", + "platform" : "ios", + "scale" : "3x", + "size" : "20x20" }, { - "size" : "29x29", - "idiom" : "iphone", - "filename" : "iPhone-Settings-29pt.png", - "scale" : "1x" - }, - { - "size" : "29x29", - "idiom" : "iphone", "filename" : "iPhone-Settings-29pt@2x.png", - "scale" : "2x" + "idiom" : "universal", + "platform" : "ios", + "scale" : "2x", + "size" : "29x29" }, { - "size" : "29x29", - "idiom" : "iphone", "filename" : "iPhone-Settings-29pt@3x.png", - "scale" : "3x" + "idiom" : "universal", + "platform" : "ios", + "scale" : "3x", + "size" : "29x29" + }, + { + "filename" : "iPad-App-76pt.png", + "idiom" : "universal", + "platform" : "ios", + "scale" : "2x", + "size" : "38x38" + }, + { + "idiom" : "universal", + "platform" : "ios", + "scale" : "3x", + "size" : "38x38" }, { - "size" : "40x40", - "idiom" : "iphone", "filename" : "iPhone-Spotlight-40pt@2x.png", - "scale" : "2x" + "idiom" : "universal", + "platform" : "ios", + "scale" : "2x", + "size" : "40x40" }, { - "size" : "40x40", - "idiom" : "iphone", "filename" : "iPhone-Spotlight-40pt@3x.png", - "scale" : "3x" + "idiom" : "universal", + "platform" : "ios", + "scale" : "3x", + "size" : "40x40" }, { - "size" : "60x60", - "idiom" : "iphone", - "filename" : "iPhone-App-60pt@2x.png", - "scale" : "2x" + "filename" : "iPhone-Spotlight-40pt@3x 1.png", + "idiom" : "universal", + "platform" : "ios", + "scale" : "2x", + "size" : "60x60" }, { - "size" : "60x60", - "idiom" : "iphone", "filename" : "iPhone-App-60pt@3x.png", - "scale" : "3x" + "idiom" : "universal", + "platform" : "ios", + "scale" : "3x", + "size" : "60x60" + }, + { + "idiom" : "universal", + "platform" : "ios", + "scale" : "2x", + "size" : "64x64" }, { - "size" : "20x20", - "idiom" : "ipad", - "filename" : "iPad-Notification-20pt.png", - "scale" : "1x" + "idiom" : "universal", + "platform" : "ios", + "scale" : "3x", + "size" : "64x64" }, { - "size" : "20x20", - "idiom" : "ipad", - "filename" : "iPad-Notification-20pt@2x.png", - "scale" : "2x" + "idiom" : "universal", + "platform" : "ios", + "scale" : "2x", + "size" : "68x68" }, { - "size" : "29x29", - "idiom" : "ipad", - "filename" : "iPad-Settings-29pt.png", - "scale" : "1x" + "filename" : "iPad-App-76pt@2x.png", + "idiom" : "universal", + "platform" : "ios", + "scale" : "2x", + "size" : "76x76" }, { - "size" : "29x29", - "idiom" : "ipad", - "filename" : "iPad-Settings-29pt@2x.png", - "scale" : "2x" + "filename" : "iPad-Pro-App-83.5pt@2x.png", + "idiom" : "universal", + "platform" : "ios", + "scale" : "2x", + "size" : "83.5x83.5" }, { - "size" : "40x40", - "idiom" : "ipad", - "filename" : "iPad-Spotlight-40pt.png", - "scale" : "1x" + "filename" : "App-Store-iOS-1024pt.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" }, { - "size" : "40x40", - "idiom" : "ipad", - "filename" : "iPad-Spotlight-40pt@2x.png", - "scale" : "2x" + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "Icon-20@2x.png", + "idiom" : "universal", + "platform" : "ios", + "scale" : "2x", + "size" : "20x20" }, { - "size" : "76x76", - "idiom" : "ipad", - "filename" : "iPad-App-76pt.png", - "scale" : "1x" + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "Icon-20@3x.png", + "idiom" : "universal", + "platform" : "ios", + "scale" : "3x", + "size" : "20x20" }, { - "size" : "76x76", - "idiom" : "ipad", - "filename" : "iPad-App-76pt@2x.png", - "scale" : "2x" + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "Icon-29@2x.png", + "idiom" : "universal", + "platform" : "ios", + "scale" : "2x", + "size" : "29x29" }, { - "size" : "83.5x83.5", - "idiom" : "ipad", - "filename" : "iPad-Pro-App-83.5pt@2x.png", - "scale" : "2x" + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "Icon-29@3x.png", + "idiom" : "universal", + "platform" : "ios", + "scale" : "3x", + "size" : "29x29" }, { - "size" : "1024x1024", - "idiom" : "ios-marketing", - "filename" : "App-Store-iOS-1024pt.png", - "scale" : "1x" + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "Icon-76@1x.png", + "idiom" : "universal", + "platform" : "ios", + "scale" : "2x", + "size" : "38x38" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "idiom" : "universal", + "platform" : "ios", + "scale" : "3x", + "size" : "38x38" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "Icon-40@2x.png", + "idiom" : "universal", + "platform" : "ios", + "scale" : "2x", + "size" : "40x40" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "Icon-40@3x.png", + "idiom" : "universal", + "platform" : "ios", + "scale" : "3x", + "size" : "40x40" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "Icon-60@2x.png", + "idiom" : "universal", + "platform" : "ios", + "scale" : "2x", + "size" : "60x60" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "Icon-60@3x.png", + "idiom" : "universal", + "platform" : "ios", + "scale" : "3x", + "size" : "60x60" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "idiom" : "universal", + "platform" : "ios", + "scale" : "2x", + "size" : "64x64" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "idiom" : "universal", + "platform" : "ios", + "scale" : "3x", + "size" : "64x64" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "idiom" : "universal", + "platform" : "ios", + "scale" : "2x", + "size" : "68x68" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "Icon-76@2x.png", + "idiom" : "universal", + "platform" : "ios", + "scale" : "2x", + "size" : "76x76" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "Icon-83.5@2x.png", + "idiom" : "universal", + "platform" : "ios", + "scale" : "2x", + "size" : "83.5x83.5" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "Icon-1024@1x.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "idiom" : "universal", + "platform" : "ios", + "scale" : "2x", + "size" : "20x20" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "idiom" : "universal", + "platform" : "ios", + "scale" : "3x", + "size" : "20x20" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "idiom" : "universal", + "platform" : "ios", + "scale" : "2x", + "size" : "29x29" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "idiom" : "universal", + "platform" : "ios", + "scale" : "3x", + "size" : "29x29" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "idiom" : "universal", + "platform" : "ios", + "scale" : "2x", + "size" : "38x38" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "idiom" : "universal", + "platform" : "ios", + "scale" : "3x", + "size" : "38x38" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "idiom" : "universal", + "platform" : "ios", + "scale" : "2x", + "size" : "40x40" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "idiom" : "universal", + "platform" : "ios", + "scale" : "3x", + "size" : "40x40" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "idiom" : "universal", + "platform" : "ios", + "scale" : "2x", + "size" : "60x60" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "idiom" : "universal", + "platform" : "ios", + "scale" : "3x", + "size" : "60x60" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "idiom" : "universal", + "platform" : "ios", + "scale" : "2x", + "size" : "64x64" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "idiom" : "universal", + "platform" : "ios", + "scale" : "3x", + "size" : "64x64" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "idiom" : "universal", + "platform" : "ios", + "scale" : "2x", + "size" : "68x68" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "idiom" : "universal", + "platform" : "ios", + "scale" : "2x", + "size" : "76x76" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "idiom" : "universal", + "platform" : "ios", + "scale" : "2x", + "size" : "83.5x83.5" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" } ], "info" : { - "version" : 1, - "author" : "xcode" + "author" : "xcode", + "version" : 1 } } diff --git a/Media.xcassets/AppIcon.appiconset/Icon-1024@1x.png b/Media.xcassets/AppIcon.appiconset/Icon-1024@1x.png new file mode 100644 index 000000000..877cb33c2 Binary files /dev/null and b/Media.xcassets/AppIcon.appiconset/Icon-1024@1x.png differ diff --git a/Media.xcassets/AppIcon.appiconset/Icon-20@2x.png b/Media.xcassets/AppIcon.appiconset/Icon-20@2x.png new file mode 100644 index 000000000..c785967bc Binary files /dev/null and b/Media.xcassets/AppIcon.appiconset/Icon-20@2x.png differ diff --git a/Media.xcassets/AppIcon.appiconset/Icon-20@3x.png b/Media.xcassets/AppIcon.appiconset/Icon-20@3x.png new file mode 100644 index 000000000..f8f45e002 Binary files /dev/null and b/Media.xcassets/AppIcon.appiconset/Icon-20@3x.png differ diff --git a/Media.xcassets/AppIcon.appiconset/Icon-29@2x.png b/Media.xcassets/AppIcon.appiconset/Icon-29@2x.png new file mode 100644 index 000000000..83d708277 Binary files /dev/null and b/Media.xcassets/AppIcon.appiconset/Icon-29@2x.png differ diff --git a/Media.xcassets/AppIcon.appiconset/Icon-29@3x.png b/Media.xcassets/AppIcon.appiconset/Icon-29@3x.png new file mode 100644 index 000000000..35ae7edfb Binary files /dev/null and b/Media.xcassets/AppIcon.appiconset/Icon-29@3x.png differ diff --git a/Media.xcassets/AppIcon.appiconset/Icon-40@2x.png b/Media.xcassets/AppIcon.appiconset/Icon-40@2x.png new file mode 100644 index 000000000..e1d6c6d31 Binary files /dev/null and b/Media.xcassets/AppIcon.appiconset/Icon-40@2x.png differ diff --git a/Media.xcassets/AppIcon.appiconset/Icon-40@3x.png b/Media.xcassets/AppIcon.appiconset/Icon-40@3x.png new file mode 100644 index 000000000..e4083b290 Binary files /dev/null and b/Media.xcassets/AppIcon.appiconset/Icon-40@3x.png differ diff --git a/Media.xcassets/AppIcon.appiconset/Icon-60@2x.png b/Media.xcassets/AppIcon.appiconset/Icon-60@2x.png new file mode 100644 index 000000000..e4083b290 Binary files /dev/null and b/Media.xcassets/AppIcon.appiconset/Icon-60@2x.png differ diff --git a/Media.xcassets/AppIcon.appiconset/Icon-60@3x.png b/Media.xcassets/AppIcon.appiconset/Icon-60@3x.png new file mode 100644 index 000000000..cb5f7f614 Binary files /dev/null and b/Media.xcassets/AppIcon.appiconset/Icon-60@3x.png differ diff --git a/Media.xcassets/AppIcon.appiconset/Icon-76@1x.png b/Media.xcassets/AppIcon.appiconset/Icon-76@1x.png new file mode 100644 index 000000000..727e9d71c Binary files /dev/null and b/Media.xcassets/AppIcon.appiconset/Icon-76@1x.png differ diff --git a/Media.xcassets/AppIcon.appiconset/Icon-76@2x.png b/Media.xcassets/AppIcon.appiconset/Icon-76@2x.png new file mode 100644 index 000000000..2318a7081 Binary files /dev/null and b/Media.xcassets/AppIcon.appiconset/Icon-76@2x.png differ diff --git a/Media.xcassets/AppIcon.appiconset/Icon-83.5@2x.png b/Media.xcassets/AppIcon.appiconset/Icon-83.5@2x.png new file mode 100644 index 000000000..3ff37d940 Binary files /dev/null and b/Media.xcassets/AppIcon.appiconset/Icon-83.5@2x.png differ diff --git a/Media.xcassets/AppIcon.appiconset/iPad-Notification-20pt.png b/Media.xcassets/AppIcon.appiconset/iPad-Notification-20pt.png deleted file mode 100644 index 0010bbe11..000000000 Binary files a/Media.xcassets/AppIcon.appiconset/iPad-Notification-20pt.png and /dev/null differ diff --git a/Media.xcassets/AppIcon.appiconset/iPad-Notification-20pt@2x.png b/Media.xcassets/AppIcon.appiconset/iPad-Notification-20pt@2x.png deleted file mode 100644 index a90a10367..000000000 Binary files a/Media.xcassets/AppIcon.appiconset/iPad-Notification-20pt@2x.png and /dev/null differ diff --git a/Media.xcassets/AppIcon.appiconset/iPad-Settings-29pt.png b/Media.xcassets/AppIcon.appiconset/iPad-Settings-29pt.png deleted file mode 100644 index ddf7a2527..000000000 Binary files a/Media.xcassets/AppIcon.appiconset/iPad-Settings-29pt.png and /dev/null differ diff --git a/Media.xcassets/AppIcon.appiconset/iPad-Settings-29pt@2x.png b/Media.xcassets/AppIcon.appiconset/iPad-Settings-29pt@2x.png deleted file mode 100644 index bcc9a0bb2..000000000 Binary files a/Media.xcassets/AppIcon.appiconset/iPad-Settings-29pt@2x.png and /dev/null differ diff --git a/Media.xcassets/AppIcon.appiconset/iPad-Spotlight-40pt.png b/Media.xcassets/AppIcon.appiconset/iPad-Spotlight-40pt.png deleted file mode 100644 index a90a10367..000000000 Binary files a/Media.xcassets/AppIcon.appiconset/iPad-Spotlight-40pt.png and /dev/null differ diff --git a/Media.xcassets/AppIcon.appiconset/iPad-Spotlight-40pt@2x.png b/Media.xcassets/AppIcon.appiconset/iPad-Spotlight-40pt@2x.png deleted file mode 100644 index 814117a6d..000000000 Binary files a/Media.xcassets/AppIcon.appiconset/iPad-Spotlight-40pt@2x.png and /dev/null differ diff --git a/Media.xcassets/AppIcon.appiconset/iPhone-Settings-29pt.png b/Media.xcassets/AppIcon.appiconset/iPhone-Settings-29pt.png deleted file mode 100644 index ddf7a2527..000000000 Binary files a/Media.xcassets/AppIcon.appiconset/iPhone-Settings-29pt.png and /dev/null differ diff --git a/Media.xcassets/AppIcon.appiconset/iPhone-App-60pt@2x.png b/Media.xcassets/AppIcon.appiconset/iPhone-Spotlight-40pt@3x 1.png similarity index 100% rename from Media.xcassets/AppIcon.appiconset/iPhone-App-60pt@2x.png rename to Media.xcassets/AppIcon.appiconset/iPhone-Spotlight-40pt@3x 1.png diff --git a/Media.xcassets/intro-1.imageset/Contents.json b/Media.xcassets/intro-1.imageset/Contents.json new file mode 100644 index 000000000..5d775d0a2 --- /dev/null +++ b/Media.xcassets/intro-1.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "intro-1.jpg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Media.xcassets/intro-1.imageset/intro-1.jpg b/Media.xcassets/intro-1.imageset/intro-1.jpg new file mode 100644 index 000000000..df8845d5e Binary files /dev/null and b/Media.xcassets/intro-1.imageset/intro-1.jpg differ diff --git a/Media.xcassets/intro-2.imageset/Contents.json b/Media.xcassets/intro-2.imageset/Contents.json new file mode 100644 index 000000000..4ba5ad095 --- /dev/null +++ b/Media.xcassets/intro-2.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "intro-2.jpg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Media.xcassets/intro-2.imageset/intro-2.jpg b/Media.xcassets/intro-2.imageset/intro-2.jpg new file mode 100644 index 000000000..8d9a483ac Binary files /dev/null and b/Media.xcassets/intro-2.imageset/intro-2.jpg differ diff --git a/Media.xcassets/intro-3.imageset/Contents.json b/Media.xcassets/intro-3.imageset/Contents.json new file mode 100644 index 000000000..a480df716 --- /dev/null +++ b/Media.xcassets/intro-3.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "intro-3.jpg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Media.xcassets/intro-3.imageset/intro-3.jpg b/Media.xcassets/intro-3.imageset/intro-3.jpg new file mode 100644 index 000000000..7b4d8845a Binary files /dev/null and b/Media.xcassets/intro-3.imageset/intro-3.jpg differ diff --git a/Media.xcassets/intro-4.imageset/Contents.json b/Media.xcassets/intro-4.imageset/Contents.json new file mode 100644 index 000000000..1bfb966fa --- /dev/null +++ b/Media.xcassets/intro-4.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "intro-4.jpg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Media.xcassets/intro-4.imageset/intro-4.jpg b/Media.xcassets/intro-4.imageset/intro-4.jpg new file mode 100644 index 000000000..9565e3dc8 Binary files /dev/null and b/Media.xcassets/intro-4.imageset/intro-4.jpg differ diff --git a/Media.xcassets/intro-5.imageset/Contents.json b/Media.xcassets/intro-5.imageset/Contents.json new file mode 100644 index 000000000..6773c85d0 --- /dev/null +++ b/Media.xcassets/intro-5.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "intro-5.jpg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Media.xcassets/intro-5.imageset/intro-5.jpg b/Media.xcassets/intro-5.imageset/intro-5.jpg new file mode 100644 index 000000000..98141026e Binary files /dev/null and b/Media.xcassets/intro-5.imageset/intro-5.jpg differ diff --git a/Media.xcassets/intro-6.imageset/Contents.json b/Media.xcassets/intro-6.imageset/Contents.json new file mode 100644 index 000000000..f3b33cd37 --- /dev/null +++ b/Media.xcassets/intro-6.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "intro-6.jpg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Media.xcassets/intro-6.imageset/intro-6.jpg b/Media.xcassets/intro-6.imageset/intro-6.jpg new file mode 100644 index 000000000..1c9687887 Binary files /dev/null and b/Media.xcassets/intro-6.imageset/intro-6.jpg differ diff --git a/README.md b/README.md index caa135845..cd02ae9ba 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ compress, uncompress, gzip, gunzip, * You can call commands individually, or use small scripts using python or lua. There is redirection (">", "<", "&>" ...), but no pipe. -All these commands are inside the `ios_system.framework` (precompiled, for facility). If you want to edit the source (to add more commands), see: https://github.com/holzschu/ios_system . +All these commands are inside the `ios_system.framework` (precompiled, for facility). If you want to edit the source (to add more commands), see: https://github.com/holzschu/ios_system. curl opens access to file transfers to and from your iPad (ftp, http, scp, sftp...). It uses the key management of BLINKSHELL (the keys you created with "config"). You can also specify keys with a path: ``` @@ -67,7 +67,7 @@ Blink is available now on the [AppStore](https://itunes.apple.com/app/id11567075 If you would like to participate on its development, we would love to have you on board! There are two ways to collaborate with the project: you can download and build Blink yourself, or you can request an invitation to help us test future versions (on the raw branch). If you want to participate on the testing, follow and tweet us [@BlinkShell](https://twitter.com/BlinkShell) about your usage scenarios. Invitations will be sent out in waves, please be patient if you do not receive yours immediately. -Bugs should be reported here on GitHub. Crash reports will be automatically reported back to us thanks to HockeyApp. If you have any questions or want to make sure we do not miss on an interesting feature, please send your suggestions to our Twitter account [@BlinkShell](https://twitter.com/BlinkShell). We would love to discuss them with you! Please do not use Twitter to report bugs. +Bugs should be reported here on GitHub. If you have any questions or want to make sure we do not miss on an interesting feature, please send your suggestions to our Twitter account [@BlinkShell](https://twitter.com/BlinkShell). We would love to discuss them with you! Please do not use Twitter to report bugs. We can't wait to receive your valuable feedback. Enjoy! @@ -76,10 +76,12 @@ We can't wait to receive your valuable feedback. Enjoy! We made a ton easier to build and install Blink yourself on your iOS devices through XCode. We provide a precompiled package with all the libraries for the master branch. Here are the steps: +0. Check `xcode-select -p` is pointing to Xcode.app (`/Applications/Xcode.app/Contents/Developer`) not command tools. + 1. Run the following command: ```bash git clone --recursive https://github.com/blinksh/blink.git && \ - cd blink && ./get_frameworks.sh && \ + cd blink && ./get_frameworks.sh && ./get_resources.sh && \ rm -rf Blink.xcodeproj/project.xcworkspace/xcshareddata/ ``` diff --git a/Resources/blink-uio.min.js b/Resources/blink-uio.min.js index 5f5f5b5ee..1b135bb35 100644 --- a/Resources/blink-uio.min.js +++ b/Resources/blink-uio.min.js @@ -1 +1 @@ -function _defineProperty(obj,key,value){if(key in obj){Object.defineProperty(obj,key,{value:value,enumerable:true,configurable:true,writable:true})}else{obj[key]=value}return obj}function _objectSpread(target){for(var i=1;iString.fromCharCode(a.charCodeAt(0)-64),_unknownKeyDef={keyCode:0,keyCap:"[Unidentified]",normal:PASS,ctrl:PASS,alt:PASS,meta:PASS};class KeyMap{getKeyDef(a1){var b=this._defs[a1];return b||(console.warn(`No definition for (keyCode ${a1})`),b=_unknownKeyDef,this.addKeyDef(a1,b)),b}addKeyDef(c1,d1){if(c1 in this._defs&&console.warn("Dup keyCode: ",c1),this._defs[c1]=d1,/^\[\w+\]$/.test(d1.keyCap)){let e=d1.keyCap.replace(/\W/g,"");this._reverseDefs[e]=d1}else{var f=d1.keyCap[0];this._reverseDefs[f]=d1,/0-9/.test(f)?this._reverseDefs["Digit"+f]=d1:/[a-z]/.test(f)&&(this._reverseDefs["Key"+f.toUpperCase()]=d1)}}reset(){this._defs={};const g=(a,b,c)=>"function"==typeof a?a.call(this,b,c):a,h=(a,b)=>(c,d)=>g(c.shift||c.ctrl||c.alt||c.meta?b:a,c,d),i=(a,b)=>(c,d)=>{let e=c.shift?b:a;return c.shift=!1,g(e,c,d)},j=(a,b)=>(c,d)=>g(c.alt?a:b,c,d),k=(a,b)=>(c,d)=>g(c.shift||c.ctrl||c.alt||c.meta?a:b,c,d),l=a=>(b,c)=>g(this._keyboard.hasSelection?this._onSel:a,b,c),m=a=>this.addKeyDef(a.keyCode,a);m(_unknownKeyDef),m({keyCode:27,code:"Escape",keyCap:"[Escape]",normal:l("\x1b"),ctrl:DEFAULT,alt:DEFAULT,meta:DEFAULT}),m({keyCode:112,code:"F1",keyCap:"[F1]",normal:h("\x1bOP","\x1b[P"),ctrl:DEFAULT,alt:"\x1b[23~",meta:DEFAULT}),m({keyCode:113,code:"F2",keyCap:"[F2]",normal:h("\x1bOQ","\x1b[Q"),ctrl:DEFAULT,alt:"\x1b[24~",meta:DEFAULT}),m({keyCode:114,code:"F3",keyCap:"[F3]",normal:h("\x1bOR","\x1b[R"),ctrl:DEFAULT,alt:"\x1b[25~",meta:DEFAULT}),m({keyCode:115,code:"F4",keyCap:"[F4]",normal:h("\x1bOS","\x1b[S"),ctrl:DEFAULT,alt:"\x1b[26~",meta:DEFAULT}),m({keyCode:116,code:"F5",keyCap:"[F5]",normal:"\x1b[15~",ctrl:DEFAULT,alt:"\x1b[28~",meta:DEFAULT}),m({keyCode:117,code:"F6",keyCap:"[F6]",normal:"\x1b[17~",ctrl:DEFAULT,alt:"\x1b[29~",meta:DEFAULT}),m({keyCode:118,code:"F7",keyCap:"[F7]",normal:"\x1b[18~",ctrl:DEFAULT,alt:"\x1b[31~",meta:DEFAULT}),m({keyCode:119,code:"F8",keyCap:"[F8]",normal:"\x1b[19~",ctrl:DEFAULT,alt:"\x1b[32~",meta:DEFAULT}),m({keyCode:120,code:"F9",keyCap:"[F9]",normal:"\x1b[20~",ctrl:DEFAULT,alt:"\x1b[33~",meta:DEFAULT}),m({keyCode:121,code:"F10",keyCap:"[F10]",normal:"\x1b[21~",ctrl:DEFAULT,alt:"\x1b[34~",meta:DEFAULT}),m({keyCode:122,code:"F11",keyCap:"[F11]",normal:"\x1b[23~",ctrl:DEFAULT,alt:"\x1b[42~",meta:DEFAULT}),m({keyCode:123,code:"F12",keyCap:"[F12]",normal:"\x1b[24~",ctrl:DEFAULT,alt:"\x1b[43~",meta:DEFAULT});const n=this._onCtrlNum,o=this._onAltNum;m({keyCode:192,code:"Backquote",keyCap:"`~",normal:DEFAULT,ctrl:i(ctl("@"),ctl("^")),alt:DEFAULT,meta:DEFAULT}),m({keyCode:49,code:"Digit1",keyCap:"1!",normal:DEFAULT,ctrl:n,alt:o,meta:DEFAULT}),m({keyCode:50,code:"Digit2",keyCap:"2@",normal:DEFAULT,ctrl:n,alt:o,meta:DEFAULT}),m({keyCode:51,code:"Digit3",keyCap:"3#",normal:DEFAULT,ctrl:n,alt:o,meta:DEFAULT}),m({keyCode:52,code:"Digit4",keyCap:"4$",normal:DEFAULT,ctrl:n,alt:o,meta:DEFAULT}),m({keyCode:53,code:"Digit5",keyCap:"5%",normal:DEFAULT,ctrl:n,alt:o,meta:DEFAULT}),m({keyCode:54,code:"Digit6",keyCap:"6^",normal:DEFAULT,ctrl:n,alt:o,meta:DEFAULT}),m({keyCode:55,code:"Digit7",keyCap:"7&",normal:DEFAULT,ctrl:n,alt:o,meta:DEFAULT}),m({keyCode:56,code:"Digit8",keyCap:"8*",normal:DEFAULT,ctrl:n,alt:o,meta:DEFAULT}),m({keyCode:57,code:"Digit9",keyCap:"9(",normal:DEFAULT,ctrl:n,alt:o,meta:DEFAULT}),m({keyCode:48,code:"Digit0",keyCap:"0)",normal:DEFAULT,ctrl:DEFAULT,alt:o,meta:DEFAULT}),m({keyCode:189,code:"Minus",keyCap:"-_",normal:DEFAULT,ctrl:ctl("_"),alt:DEFAULT,meta:DEFAULT}),m({keyCode:187,code:"Equal",keyCap:"=+",normal:DEFAULT,ctrl:DEFAULT,alt:DEFAULT,meta:DEFAULT}),m({keyCode:8,code:"Backspace",keyCap:"[Backspace]",normal:"\x7f",ctrl:"\b",alt:DEFAULT,meta:DEFAULT}),m({keyCode:9,code:"Tab",keyCap:"[Tab]",normal:i("\t","\x1b[Z"),ctrl:STRIP,alt:STRIP,meta:DEFAULT}),m({keyCode:81,code:"KeyQ",keyCap:"qQ",normal:DEFAULT,ctrl:ctl("Q"),alt:DEFAULT,meta:DEFAULT}),m({keyCode:87,code:"KeyW",keyCap:"wW",normal:l(DEFAULT),ctrl:ctl("W"),alt:l(DEFAULT),meta:DEFAULT}),m({keyCode:69,code:"KeyE",keyCap:"eE",normal:DEFAULT,ctrl:ctl("E"),alt:DEFAULT,meta:DEFAULT}),m({keyCode:82,code:"KeyR",keyCap:"rR",normal:DEFAULT,ctrl:ctl("R"),alt:DEFAULT,meta:DEFAULT}),m({keyCode:84,code:"KeyT",keyCap:"tT",normal:DEFAULT,ctrl:ctl("T"),alt:DEFAULT,meta:DEFAULT}),m({keyCode:89,code:"KeyY",keyCap:"yY",normal:l(DEFAULT),ctrl:ctl("Y"),alt:DEFAULT,meta:DEFAULT}),m({keyCode:85,code:"KeyU",keyCap:"uU",normal:DEFAULT,ctrl:ctl("U"),alt:DEFAULT,meta:DEFAULT}),m({keyCode:73,code:"KeyI",keyCap:"iI",normal:DEFAULT,ctrl:ctl("I"),alt:DEFAULT,meta:DEFAULT}),m({keyCode:79,code:"KeyO",keyCap:"oO",normal:l(DEFAULT),ctrl:ctl("O"),alt:DEFAULT,meta:DEFAULT}),m({keyCode:80,code:"KeyP",keyCap:"pP",normal:l(DEFAULT),ctrl:l(ctl("P")),alt:DEFAULT,meta:DEFAULT}),m({keyCode:219,code:"BracketLeft",keyCap:"[{",normal:DEFAULT,ctrl:ctl("["),alt:DEFAULT,meta:DEFAULT}),m({keyCode:221,code:"BracketRight",keyCap:"]}",normal:DEFAULT,ctrl:ctl("]"),alt:DEFAULT,meta:DEFAULT}),m({keyCode:220,code:"Backslash",keyCap:"\\|",normal:DEFAULT,ctrl:ctl("\\"),alt:DEFAULT,meta:DEFAULT}),m({keyCode:20,code:"CapsLock",keyCap:"[CapsLock]",normal:PASS,ctrl:PASS,alt:PASS,meta:DEFAULT}),m({keyCode:65,code:"KeyA",keyCap:"aA",normal:DEFAULT,ctrl:ctl("A"),alt:DEFAULT,meta:DEFAULT}),m({keyCode:83,code:"KeyS",keyCap:"sS",normal:DEFAULT,ctrl:ctl("S"),alt:DEFAULT,meta:DEFAULT}),m({keyCode:68,code:"KeyD",keyCap:"dD",normal:DEFAULT,ctrl:ctl("D"),alt:DEFAULT,meta:DEFAULT}),m({keyCode:70,code:"KeyF",keyCap:"fF",normal:DEFAULT,ctrl:l(ctl("F")),alt:l(DEFAULT),meta:DEFAULT}),m({keyCode:71,code:"KeyG",keyCap:"gG",normal:DEFAULT,ctrl:ctl("G"),alt:DEFAULT,meta:DEFAULT}),m({keyCode:72,code:"KeyH",keyCap:"hH",normal:l(DEFAULT),ctrl:ctl("H"),alt:DEFAULT,meta:DEFAULT}),m({keyCode:74,code:"KeyJ",keyCap:"jJ",normal:l(DEFAULT),ctrl:ctl("J"),alt:DEFAULT,meta:DEFAULT}),m({keyCode:75,code:"KeyK",keyCap:"kK",normal:l(DEFAULT),ctrl:ctl("K"),alt:DEFAULT,meta:DEFAULT}),m({keyCode:76,code:"KeyL",keyCap:"lL",normal:l(DEFAULT),ctrl:ctl("L"),alt:DEFAULT,meta:DEFAULT}),m({keyCode:186,code:"Semicolon",keyCap:";:",normal:DEFAULT,ctrl:STRIP,alt:DEFAULT,meta:DEFAULT}),m({keyCode:222,code:"Quote",keyCap:"'\"",normal:DEFAULT,ctrl:STRIP,alt:DEFAULT,meta:DEFAULT}),m({keyCode:13,code:"Enter",keyCap:"[Enter]",normal:"\r",ctrl:DEFAULT,alt:DEFAULT,meta:DEFAULT}),m({keyCode:16,code:"ShiftLeft",keyCap:"[Shift]",normal:PASS,ctrl:PASS,alt:PASS,meta:DEFAULT}),m({keyCode:90,code:"KeyZ",keyCap:"zZ",normal:DEFAULT,ctrl:ctl("Z"),alt:DEFAULT,meta:DEFAULT}),m({keyCode:88,code:"KeyX",keyCap:"xX",normal:l(DEFAULT),ctrl:l(ctl("X")),alt:DEFAULT,meta:DEFAULT}),m({keyCode:67,code:"KeyC",keyCap:"cC",normal:DEFAULT,ctrl:ctl("C"),alt:DEFAULT,meta:DEFAULT}),m({keyCode:86,code:"KeyV",keyCap:"vV",normal:DEFAULT,ctrl:ctl("V"),alt:DEFAULT,meta:DEFAULT}),m({keyCode:66,code:"KeyB",keyCap:"bB",normal:l(DEFAULT),ctrl:l(ctl("B")),alt:l(DEFAULT),meta:DEFAULT}),m({keyCode:78,code:"KeyN",keyCap:"nN",normal:DEFAULT,ctrl:l(ctl("N")),alt:DEFAULT,meta:DEFAULT}),m({keyCode:77,code:"KeyM",keyCap:"mM",normal:DEFAULT,ctrl:ctl("M"),alt:DEFAULT,meta:DEFAULT}),m({keyCode:188,code:"Comma",keyCap:",<",normal:DEFAULT,ctrl:j(STRIP,PASS),alt:DEFAULT,meta:DEFAULT}),m({keyCode:190,code:"Period",keyCap:".>",normal:DEFAULT,ctrl:j(STRIP,PASS),alt:DEFAULT,meta:DEFAULT}),m({keyCode:191,code:"Slash",keyCap:"/?",normal:DEFAULT,ctrl:i(ctl("_"),ctl("?")),alt:DEFAULT,meta:DEFAULT}),m({keyCode:17,code:"ControlLeft",keyCap:"[Control]",normal:PASS,ctrl:PASS,alt:PASS,meta:PASS}),m({keyCode:18,code:"AltLeft",keyCap:"[Alt]",normal:PASS,ctrl:PASS,alt:PASS,meta:PASS}),m({keyCode:91,code:"MetaLeft",keyCap:"[Meta]",normal:PASS,ctrl:PASS,alt:PASS,meta:PASS}),m({keyCode:32,code:"Space",keyCap:" ",normal:DEFAULT,ctrl:ctl("@"),alt:DEFAULT,meta:DEFAULT}),m({keyCode:93,code:"MetaRight",keyCap:"[Meta]",normal:PASS,ctrl:PASS,alt:PASS,meta:PASS}),m({keyCode:42,code:"F13",keyCap:"[PRTSCR]",normal:PASS,ctrl:PASS,alt:PASS,meta:PASS}),m({keyCode:145,code:"F14",keyCap:"[SCRLK]",normal:PASS,ctrl:PASS,alt:PASS,meta:PASS}),m({keyCode:19,code:"F15",keyCap:"[BREAK]",normal:PASS,ctrl:PASS,alt:PASS,meta:PASS}),m({keyCode:45,code:"Help",keyCap:"[Insert]",normal:"\x1b[2~",ctrl:DEFAULT,alt:DEFAULT,meta:DEFAULT}),m({keyCode:36,code:"Home",keyCap:"[Home]",normal:"\x1bOH",ctrl:DEFAULT,alt:DEFAULT,meta:DEFAULT}),m({keyCode:33,code:"PageUp",keyCap:"[PageUp]",normal:"\x1b[5~",ctrl:DEFAULT,alt:DEFAULT,meta:DEFAULT}),m({keyCode:46,code:"Delete",keyCap:"[DEL]",normal:"\x1b[3~",ctrl:DEFAULT,alt:DEFAULT,meta:DEFAULT}),m({keyCode:35,code:"End",keyCap:"[End]",normal:"\x1bOF",ctrl:DEFAULT,alt:DEFAULT,meta:DEFAULT}),m({keyCode:34,code:"PageDown",keyCap:"[PageDown]",normal:"\x1b[6~",ctrl:DEFAULT,alt:DEFAULT,meta:DEFAULT}),m({keyCode:38,code:"ArrowUp",keyCap:"[ArrowUp]",normal:l(k("\x1b[A","\x1bOA")),ctrl:DEFAULT,alt:DEFAULT,meta:DEFAULT}),m({keyCode:40,code:"ArrowDown",keyCap:"[ArrowDown]",normal:l(k("\x1b[B","\x1bOB")),ctrl:DEFAULT,alt:DEFAULT,meta:DEFAULT}),m({keyCode:39,code:"ArrowRight",keyCap:"[ArrowRight]",normal:l(k("\x1b[C","\x1bOC")),ctrl:DEFAULT,alt:DEFAULT,meta:DEFAULT}),m({keyCode:37,code:"ArrowLeft",keyCap:"[ArrowLeft]",normal:l(k("\x1b[D","\x1bOD")),ctrl:DEFAULT,alt:DEFAULT,meta:DEFAULT}),m({keyCode:144,code:"NumLock",keyCap:"[NumLock]",normal:PASS,ctrl:PASS,alt:PASS,meta:PASS}),m({keyCode:12,code:"",keyCap:"[Clear]",normal:PASS,ctrl:PASS,alt:PASS,meta:PASS}),m({keyCode:96,code:"Numpad0",keyCap:"[Numpad0]",normal:DEFAULT,ctrl:DEFAULT,alt:DEFAULT,meta:DEFAULT}),m({keyCode:97,code:"Numpad1",keyCap:"[Numpad1]",normal:DEFAULT,ctrl:DEFAULT,alt:DEFAULT,meta:DEFAULT}),m({keyCode:98,code:"Numpad2",keyCap:"[Numpad2]",normal:DEFAULT,ctrl:DEFAULT,alt:DEFAULT,meta:DEFAULT}),m({keyCode:99,code:"Numpad3",keyCap:"[Numpad3]",normal:DEFAULT,ctrl:DEFAULT,alt:DEFAULT,meta:DEFAULT}),m({keyCode:100,code:"Numpad4",keyCap:"[Numpad4]",normal:DEFAULT,ctrl:DEFAULT,alt:DEFAULT,meta:DEFAULT}),m({keyCode:101,code:"Numpad5",keyCap:"[Numpad5]",normal:DEFAULT,ctrl:DEFAULT,alt:DEFAULT,meta:DEFAULT}),m({keyCode:102,code:"Numpad6",keyCap:"[Numpad6]",normal:DEFAULT,ctrl:DEFAULT,alt:DEFAULT,meta:DEFAULT}),m({keyCode:103,code:"Numpad7",keyCap:"[Numpad7]",normal:DEFAULT,ctrl:DEFAULT,alt:DEFAULT,meta:DEFAULT}),m({keyCode:104,code:"Numpad8",keyCap:"[Numpad8]",normal:DEFAULT,ctrl:DEFAULT,alt:DEFAULT,meta:DEFAULT}),m({keyCode:105,code:"Numpad9",keyCap:"[Numpad9]",normal:DEFAULT,ctrl:DEFAULT,alt:DEFAULT,meta:DEFAULT}),m({keyCode:107,code:"NumpadAdd",keyCap:"[NumpadAdd]",normal:DEFAULT,ctrl:DEFAULT,alt:DEFAULT,meta:DEFAULT}),m({keyCode:109,code:"NumpadSubtract",keyCap:"[NumpadSubtract]",normal:DEFAULT,ctrl:DEFAULT,alt:DEFAULT,meta:DEFAULT}),m({keyCode:106,code:"NumpadMultiply",keyCap:"[NumpadMultiply]",normal:DEFAULT,ctrl:DEFAULT,alt:DEFAULT,meta:DEFAULT}),m({keyCode:111,code:"NumpadDivide",keyCap:"[NumpadDivide]",normal:DEFAULT,ctrl:DEFAULT,alt:DEFAULT,meta:DEFAULT}),m({keyCode:110,code:"NumpadDecimal",keyCap:"[NumpadDecimal]",normal:DEFAULT,ctrl:DEFAULT,alt:DEFAULT,meta:DEFAULT}),this._reverseDefs.Backqoute=this._defs[192],this._reverseDefs.BracketLeft=this._defs[229],this._reverseDefs.BracketRight=this._defs[221],this._reverseDefs.Slash=this._defs[191],this._reverseDefs.Space=this._defs[32]}keyCode(p1){let q=this._reverseDefs[p1];return q?q.keyCode:0}key(r1){let s=this._defs[r1];if(!s)return"";let t=s.keyCap;return/^\[\w+\]$/.test(t)?t.substr(1,t.length-2):t.substr(0,1)}code(u1){let v=this._defs[u1];return v?v.code:""}constructor(w1){this._defs={},this._reverseDefs={},this._onCtrlNum=(a,b)=>{switch(b.keyCap.substr(0,1)){case"1":return"1";case"2":return ctl("@");case"3":return ctl("[");case"4":return ctl("\\");case"5":return ctl("]");case"6":return ctl("^");case"7":return ctl("_");case"8":return"\x7f";case"9":return"9";default:return PASS}},this._onAltNum=(a,b)=>DEFAULT,this._onSel=(a2,b)=>{let{ArrowDown:c,ArrowLeft:d,ArrowRight:e,ArrowUp:f,Escape:g,h:h,j:i,k:j,l:k,o:l,b:m,f:n,n:o,p:p,w:q,x:r,y:s}=this._reverseDefs;const t=a=>op("selection",a),u={command:"copy"};if(b===d||b===h){let v=a2.shift?"word":"character";t({dir:"left",gran:v})}else if(b===e||b===k){let w=a2.shift?"word":"character";t({dir:"right",gran:w})}else b===f||b===j?t({dir:"left",gran:"line"}):b===c||b===i?t({dir:"right",gran:"line"}):b===l||b===r?t({command:"change"}):b===o&&a2.ctrl?t({dir:"right",gran:"line"}):b===p?a2.ctrl?t({dir:"left",gran:"line"}):a2.shift||a2.alt||a2.meta||t({command:"paste"}):b===m?a2.ctrl?t({dir:"left",gran:"character"}):(a2.alt,t({dir:"left",gran:"word"})):b===q?t(a2.alt?u:{dir:"right",gran:"word"}):b===n?a2.ctrl?t({dir:"right",gran:"character"}):a2.alt&&t({dir:"right",gran:"word"}):b===s?t(u):b===g&&t({command:"cancel"});return CANCEL},this._keyboard=w1,this.reset()}}function blink_uio_activate(){var b1=0,c2=!1,d2=!1;let e1=/^[a-z0-9\[\]\{\}~`\#\$-_=\+\|"|'\?]$/i;var f=0;let g1=new KeyMap(null);function h1(a,b=document.activeElement){if(b){const[c,d]=[b.selectionStart,b.selectionEnd];b.setRangeText(a,c,d,"end"),b.dispatchEvent(new InputEvent("input"))}}function i(a,b){var c;let d=_objectSpread({},b,{op:a});null===(c=window.webkit.messageHandlers._kb)|| void 0===c||c.postMessage(d)}function j1(a){var b;if(0===a.indexOf("0:0:0:")){h1(a.replace("0:0:0:",""));return}var j=a.split(/:/g);let k=parseInt(j[0],10),l=parseInt(j[1],10),m=""==j[3]?":":j[3]||g1.key(l)||"",n=g1.code(l);if(4==j.length&&!k&&e1.test(m)){h1(m);return}let o=new KeyboardEvent("keydown",{bubbles:!0,cancelable:!0,composed:!1,isComposing:!1,keyCode:l,key:m,code:n,metaKey:(1048576&k)==1048576,ctrlKey:(262144&k)==262144,altKey:(524288&k)==524288,shiftKey:c2||d2});f=0,null===(b=document.activeElement)|| void 0===b||b.dispatchEvent(o),i("out",{data:""})}function k1(a){if(a.target===document.activeElement){var g,j=!1;if("ShiftLeft"===a.code?(c2=!0,j=!0):"ShiftRight"===a.code&&(d2=!0,j=!0),!a.__blink){let k=a.altKey||a.ctrlKey||a.shiftKey||a.metaKey;if(a.__noskip||k);else if(0===b1){a.repeat&&e1.test(a.key)?1==(f+=1)?h1(a.key,document.activeElement):f%=2:f=0;return}if(0!==b1){let l=new KeyboardEvent("keydown",{bubbles:a.bubbles,cancelable:a.cancelable,composed:a.composed,key:a.key,code:a.code,location:a.location,repeat:a.repeat,ctrlKey:(262144&b1)==262144,shiftKey:a.shiftKey,altKey:(524288&b1)==524288,metaKey:(1048576&b1)==1048576,isComposing:a.isComposing,charCode:a.charCode,keyCode:a.keyCode,which:a.which});l.__blink=!0,a.preventDefault(),a.stopPropagation(),null===(g=document.activeElement)|| void 0===g||g.dispatchEvent(l)}j||i("out",{data:""})}}}function l1(a){a.target===document.activeElement&&("ShiftLeft"===a.code?c2=!1:"ShiftRight"===a.code&&(d2=!1))}function m1(a){!!a&&(a.__blink||(a.tabIndex=-1,a.__blink=!0,a.autocapitalize="none",a.autocorrect=!1,a!==document.body&&(a.addEventListener("keydown",k1,!0),a.addEventListener("keyup",l1,!0))))}window.term_paste=function(a){h1(a)},window.term_getCurrentSelection=function(){const a=document.getSelection();if(!a||0===a.rangeCount||"Caret"===a.type)return{base:"",offset:0,text:""};const b=a.getRangeAt(0).getBoundingClientRect(),c=`{{${b.x}, ${b.y}},{${b.width},${b.height}}}`;return{base:a.baseNode.textContent,offset:a.baseOffset,text:a.toString()||"",rect:c}},document.addEventListener("selectionchange",function(){var a,b;a=term_getCurrentSelection(),null===(b=window.webkit.messageHandlers.interOp)|| void 0===b||b.postMessage({op:"selectionchange",data:a})}),window._onKB=function(a,e){switch(a){case"state-reset":b1=0,c2=!1,d2=!1,f=0;break;case"toolbar-mods":var g,h;b1=g=e,(h=document.activeElement)&&(0==g?h.readOnly=h.__readOnly:h.__readOnly=h.readOnly);break;case"toolbar-press":j1(e);break;case"press":j1(e);break}},window.addEventListener("focusout",function(a){m1(a.relatedTarget)},!0),window.addEventListener("focusin",function(a){if(m1(a.target),a.target){let b=a.target.nodeName;if("TEXTAREA"===b){var c;let d=null===(c=a.target.parentElement)|| void 0===c?void 0:c.parentElement;d&&"code"==d.role?i("hide-caret"):i("show-caret");return}if("INPUT"===b){let e=a.target.type;if("text"===e||"url"===e||"search"==e||"password"==e||"email"==e||"tel"==e){i("show-caret");return}}i("hide-caret")}},!0),m1(document.activeElement)}function __install_uio(){if(!window._onKB){blink_uio_activate(),document.body.contentEditable=!0,document.body.style="word-wrap: normal; position: inherit;";var a=document.createElement("meta");a.name="viewport",a.content="viewport-fit=cover, width=device-width, height=device-height, initial-scale=1, user-scalable=no",document.getElementsByTagName("head")[0].appendChild(a);let b={op:"browser-ready"};window.webkit.messageHandlers.interOp.postMessage(b)}}window.addEventListener("DOMContentLoaded",a=>{var b=0;const c=setInterval(()=>{b+=1,document.getElementsByClassName("monaco-workbench").length>0&&(__install_uio(),clearInterval(c)),b>22000&&clearInterval(c)},50)}) \ No newline at end of file +function _defineProperty(obj,key,value){if(key in obj){Object.defineProperty(obj,key,{value:value,enumerable:true,configurable:true,writable:true})}else{obj[key]=value}return obj}function _objectSpread(target){for(var i=1;iString.fromCharCode(a.charCodeAt(0)-64),_unknownKeyDef={keyCode:0,keyCap:"[Unidentified]",normal:PASS,ctrl:PASS,alt:PASS,meta:PASS};class KeyMap{getKeyDef(a1){var b=this._defs[a1];return b||(console.warn(`No definition for (keyCode ${a1})`),b=_unknownKeyDef,this.addKeyDef(a1,b)),b}addKeyDef(c1,d1){if(c1 in this._defs&&console.warn("Dup keyCode: ",c1),this._defs[c1]=d1,/^\[\w+\]$/.test(d1.keyCap)){let e=d1.keyCap.replace(/\W/g,"");this._reverseDefs[e]=d1}else{var f=d1.keyCap[0];this._reverseDefs[f]=d1,/0-9/.test(f)?this._reverseDefs["Digit"+f]=d1:/[a-z]/.test(f)&&(this._reverseDefs["Key"+f.toUpperCase()]=d1)}}reset(){this._defs={};const g=(a,b,c)=>"function"==typeof a?a.call(this,b,c):a,h=(a,b)=>(c,d)=>g(c.shift||c.ctrl||c.alt||c.meta?b:a,c,d),i=(a,b)=>(c,d)=>{let e=c.shift?b:a;return c.shift=!1,g(e,c,d)},j=(a,b)=>(c,d)=>g(c.alt?a:b,c,d),k=(a,b)=>(c,d)=>g(c.shift||c.ctrl||c.alt||c.meta?a:b,c,d),l=a=>(b,c)=>g(this._keyboard.hasSelection?this._onSel:a,b,c),m=a=>this.addKeyDef(a.keyCode,a);m(_unknownKeyDef),m({keyCode:27,code:"Escape",keyCap:"[Escape]",normal:l("\x1b"),ctrl:DEFAULT,alt:DEFAULT,meta:DEFAULT}),m({keyCode:112,code:"F1",keyCap:"[F1]",normal:h("\x1bOP","\x1b[P"),ctrl:DEFAULT,alt:"\x1b[23~",meta:DEFAULT}),m({keyCode:113,code:"F2",keyCap:"[F2]",normal:h("\x1bOQ","\x1b[Q"),ctrl:DEFAULT,alt:"\x1b[24~",meta:DEFAULT}),m({keyCode:114,code:"F3",keyCap:"[F3]",normal:h("\x1bOR","\x1b[R"),ctrl:DEFAULT,alt:"\x1b[25~",meta:DEFAULT}),m({keyCode:115,code:"F4",keyCap:"[F4]",normal:h("\x1bOS","\x1b[S"),ctrl:DEFAULT,alt:"\x1b[26~",meta:DEFAULT}),m({keyCode:116,code:"F5",keyCap:"[F5]",normal:"\x1b[15~",ctrl:DEFAULT,alt:"\x1b[28~",meta:DEFAULT}),m({keyCode:117,code:"F6",keyCap:"[F6]",normal:"\x1b[17~",ctrl:DEFAULT,alt:"\x1b[29~",meta:DEFAULT}),m({keyCode:118,code:"F7",keyCap:"[F7]",normal:"\x1b[18~",ctrl:DEFAULT,alt:"\x1b[31~",meta:DEFAULT}),m({keyCode:119,code:"F8",keyCap:"[F8]",normal:"\x1b[19~",ctrl:DEFAULT,alt:"\x1b[32~",meta:DEFAULT}),m({keyCode:120,code:"F9",keyCap:"[F9]",normal:"\x1b[20~",ctrl:DEFAULT,alt:"\x1b[33~",meta:DEFAULT}),m({keyCode:121,code:"F10",keyCap:"[F10]",normal:"\x1b[21~",ctrl:DEFAULT,alt:"\x1b[34~",meta:DEFAULT}),m({keyCode:122,code:"F11",keyCap:"[F11]",normal:"\x1b[23~",ctrl:DEFAULT,alt:"\x1b[42~",meta:DEFAULT}),m({keyCode:123,code:"F12",keyCap:"[F12]",normal:"\x1b[24~",ctrl:DEFAULT,alt:"\x1b[43~",meta:DEFAULT});const n=this._onCtrlNum,o=this._onAltNum;m({keyCode:192,code:"Backquote",keyCap:"`~",normal:DEFAULT,ctrl:i(ctl("@"),ctl("^")),alt:DEFAULT,meta:DEFAULT}),m({keyCode:49,code:"Digit1",keyCap:"1!",normal:DEFAULT,ctrl:n,alt:o,meta:DEFAULT}),m({keyCode:50,code:"Digit2",keyCap:"2@",normal:DEFAULT,ctrl:n,alt:o,meta:DEFAULT}),m({keyCode:51,code:"Digit3",keyCap:"3#",normal:DEFAULT,ctrl:n,alt:o,meta:DEFAULT}),m({keyCode:52,code:"Digit4",keyCap:"4$",normal:DEFAULT,ctrl:n,alt:o,meta:DEFAULT}),m({keyCode:53,code:"Digit5",keyCap:"5%",normal:DEFAULT,ctrl:n,alt:o,meta:DEFAULT}),m({keyCode:54,code:"Digit6",keyCap:"6^",normal:DEFAULT,ctrl:n,alt:o,meta:DEFAULT}),m({keyCode:55,code:"Digit7",keyCap:"7&",normal:DEFAULT,ctrl:n,alt:o,meta:DEFAULT}),m({keyCode:56,code:"Digit8",keyCap:"8*",normal:DEFAULT,ctrl:n,alt:o,meta:DEFAULT}),m({keyCode:57,code:"Digit9",keyCap:"9(",normal:DEFAULT,ctrl:n,alt:o,meta:DEFAULT}),m({keyCode:48,code:"Digit0",keyCap:"0)",normal:DEFAULT,ctrl:DEFAULT,alt:o,meta:DEFAULT}),m({keyCode:189,code:"Minus",keyCap:"-_",normal:DEFAULT,ctrl:ctl("_"),alt:DEFAULT,meta:DEFAULT}),m({keyCode:187,code:"Equal",keyCap:"=+",normal:DEFAULT,ctrl:DEFAULT,alt:DEFAULT,meta:DEFAULT}),m({keyCode:8,code:"Backspace",keyCap:"[Backspace]",normal:"\x7f",ctrl:"\b",alt:DEFAULT,meta:DEFAULT}),m({keyCode:9,code:"Tab",keyCap:"[Tab]",normal:i("\t","\x1b[Z"),ctrl:STRIP,alt:STRIP,meta:DEFAULT}),m({keyCode:81,code:"KeyQ",keyCap:"qQ",normal:DEFAULT,ctrl:ctl("Q"),alt:DEFAULT,meta:DEFAULT}),m({keyCode:87,code:"KeyW",keyCap:"wW",normal:l(DEFAULT),ctrl:ctl("W"),alt:l(DEFAULT),meta:DEFAULT}),m({keyCode:69,code:"KeyE",keyCap:"eE",normal:DEFAULT,ctrl:ctl("E"),alt:DEFAULT,meta:DEFAULT}),m({keyCode:82,code:"KeyR",keyCap:"rR",normal:DEFAULT,ctrl:ctl("R"),alt:DEFAULT,meta:DEFAULT}),m({keyCode:84,code:"KeyT",keyCap:"tT",normal:DEFAULT,ctrl:ctl("T"),alt:DEFAULT,meta:DEFAULT}),m({keyCode:89,code:"KeyY",keyCap:"yY",normal:l(DEFAULT),ctrl:ctl("Y"),alt:DEFAULT,meta:DEFAULT}),m({keyCode:85,code:"KeyU",keyCap:"uU",normal:DEFAULT,ctrl:ctl("U"),alt:DEFAULT,meta:DEFAULT}),m({keyCode:73,code:"KeyI",keyCap:"iI",normal:DEFAULT,ctrl:ctl("I"),alt:DEFAULT,meta:DEFAULT}),m({keyCode:79,code:"KeyO",keyCap:"oO",normal:l(DEFAULT),ctrl:ctl("O"),alt:DEFAULT,meta:DEFAULT}),m({keyCode:80,code:"KeyP",keyCap:"pP",normal:l(DEFAULT),ctrl:l(ctl("P")),alt:DEFAULT,meta:DEFAULT}),m({keyCode:219,code:"BracketLeft",keyCap:"[{",normal:DEFAULT,ctrl:ctl("["),alt:DEFAULT,meta:DEFAULT}),m({keyCode:221,code:"BracketRight",keyCap:"]}",normal:DEFAULT,ctrl:ctl("]"),alt:DEFAULT,meta:DEFAULT}),m({keyCode:220,code:"Backslash",keyCap:"\\|",normal:DEFAULT,ctrl:ctl("\\"),alt:DEFAULT,meta:DEFAULT}),m({keyCode:20,code:"CapsLock",keyCap:"[CapsLock]",normal:PASS,ctrl:PASS,alt:PASS,meta:DEFAULT}),m({keyCode:65,code:"KeyA",keyCap:"aA",normal:DEFAULT,ctrl:ctl("A"),alt:DEFAULT,meta:DEFAULT}),m({keyCode:83,code:"KeyS",keyCap:"sS",normal:DEFAULT,ctrl:ctl("S"),alt:DEFAULT,meta:DEFAULT}),m({keyCode:68,code:"KeyD",keyCap:"dD",normal:DEFAULT,ctrl:ctl("D"),alt:DEFAULT,meta:DEFAULT}),m({keyCode:70,code:"KeyF",keyCap:"fF",normal:DEFAULT,ctrl:l(ctl("F")),alt:l(DEFAULT),meta:DEFAULT}),m({keyCode:71,code:"KeyG",keyCap:"gG",normal:DEFAULT,ctrl:ctl("G"),alt:DEFAULT,meta:DEFAULT}),m({keyCode:72,code:"KeyH",keyCap:"hH",normal:l(DEFAULT),ctrl:ctl("H"),alt:DEFAULT,meta:DEFAULT}),m({keyCode:74,code:"KeyJ",keyCap:"jJ",normal:l(DEFAULT),ctrl:ctl("J"),alt:DEFAULT,meta:DEFAULT}),m({keyCode:75,code:"KeyK",keyCap:"kK",normal:l(DEFAULT),ctrl:ctl("K"),alt:DEFAULT,meta:DEFAULT}),m({keyCode:76,code:"KeyL",keyCap:"lL",normal:l(DEFAULT),ctrl:ctl("L"),alt:DEFAULT,meta:DEFAULT}),m({keyCode:186,code:"Semicolon",keyCap:";:",normal:DEFAULT,ctrl:STRIP,alt:DEFAULT,meta:DEFAULT}),m({keyCode:222,code:"Quote",keyCap:"'\"",normal:DEFAULT,ctrl:STRIP,alt:DEFAULT,meta:DEFAULT}),m({keyCode:13,code:"Enter",keyCap:"[Enter]",normal:"\r",ctrl:DEFAULT,alt:DEFAULT,meta:DEFAULT}),m({keyCode:16,code:"ShiftLeft",keyCap:"[Shift]",normal:PASS,ctrl:PASS,alt:PASS,meta:DEFAULT}),m({keyCode:90,code:"KeyZ",keyCap:"zZ",normal:DEFAULT,ctrl:ctl("Z"),alt:DEFAULT,meta:DEFAULT}),m({keyCode:88,code:"KeyX",keyCap:"xX",normal:l(DEFAULT),ctrl:l(ctl("X")),alt:DEFAULT,meta:DEFAULT}),m({keyCode:67,code:"KeyC",keyCap:"cC",normal:DEFAULT,ctrl:ctl("C"),alt:DEFAULT,meta:DEFAULT}),m({keyCode:86,code:"KeyV",keyCap:"vV",normal:DEFAULT,ctrl:ctl("V"),alt:DEFAULT,meta:DEFAULT}),m({keyCode:66,code:"KeyB",keyCap:"bB",normal:l(DEFAULT),ctrl:l(ctl("B")),alt:l(DEFAULT),meta:DEFAULT}),m({keyCode:78,code:"KeyN",keyCap:"nN",normal:DEFAULT,ctrl:l(ctl("N")),alt:DEFAULT,meta:DEFAULT}),m({keyCode:77,code:"KeyM",keyCap:"mM",normal:DEFAULT,ctrl:ctl("M"),alt:DEFAULT,meta:DEFAULT}),m({keyCode:188,code:"Comma",keyCap:",<",normal:DEFAULT,ctrl:j(STRIP,PASS),alt:DEFAULT,meta:DEFAULT}),m({keyCode:190,code:"Period",keyCap:".>",normal:DEFAULT,ctrl:j(STRIP,PASS),alt:DEFAULT,meta:DEFAULT}),m({keyCode:191,code:"Slash",keyCap:"/?",normal:DEFAULT,ctrl:i(ctl("_"),ctl("?")),alt:DEFAULT,meta:DEFAULT}),m({keyCode:17,code:"ControlLeft",keyCap:"[Control]",normal:PASS,ctrl:PASS,alt:PASS,meta:PASS}),m({keyCode:18,code:"AltLeft",keyCap:"[Alt]",normal:PASS,ctrl:PASS,alt:PASS,meta:PASS}),m({keyCode:91,code:"MetaLeft",keyCap:"[Meta]",normal:PASS,ctrl:PASS,alt:PASS,meta:PASS}),m({keyCode:32,code:"Space",keyCap:" ",normal:DEFAULT,ctrl:ctl("@"),alt:DEFAULT,meta:DEFAULT}),m({keyCode:93,code:"MetaRight",keyCap:"[Meta]",normal:PASS,ctrl:PASS,alt:PASS,meta:PASS}),m({keyCode:42,code:"F13",keyCap:"[PRTSCR]",normal:PASS,ctrl:PASS,alt:PASS,meta:PASS}),m({keyCode:145,code:"F14",keyCap:"[SCRLK]",normal:PASS,ctrl:PASS,alt:PASS,meta:PASS}),m({keyCode:19,code:"F15",keyCap:"[BREAK]",normal:PASS,ctrl:PASS,alt:PASS,meta:PASS}),m({keyCode:45,code:"Help",keyCap:"[Insert]",normal:"\x1b[2~",ctrl:DEFAULT,alt:DEFAULT,meta:DEFAULT}),m({keyCode:36,code:"Home",keyCap:"[Home]",normal:"\x1bOH",ctrl:DEFAULT,alt:DEFAULT,meta:DEFAULT}),m({keyCode:33,code:"PageUp",keyCap:"[PageUp]",normal:"\x1b[5~",ctrl:DEFAULT,alt:DEFAULT,meta:DEFAULT}),m({keyCode:46,code:"Delete",keyCap:"[DEL]",normal:"\x1b[3~",ctrl:DEFAULT,alt:DEFAULT,meta:DEFAULT}),m({keyCode:35,code:"End",keyCap:"[End]",normal:"\x1bOF",ctrl:DEFAULT,alt:DEFAULT,meta:DEFAULT}),m({keyCode:34,code:"PageDown",keyCap:"[PageDown]",normal:"\x1b[6~",ctrl:DEFAULT,alt:DEFAULT,meta:DEFAULT}),m({keyCode:38,code:"ArrowUp",keyCap:"[ArrowUp]",normal:l(k("\x1b[A","\x1bOA")),ctrl:DEFAULT,alt:DEFAULT,meta:DEFAULT}),m({keyCode:40,code:"ArrowDown",keyCap:"[ArrowDown]",normal:l(k("\x1b[B","\x1bOB")),ctrl:DEFAULT,alt:DEFAULT,meta:DEFAULT}),m({keyCode:39,code:"ArrowRight",keyCap:"[ArrowRight]",normal:l(k("\x1b[C","\x1bOC")),ctrl:DEFAULT,alt:DEFAULT,meta:DEFAULT}),m({keyCode:37,code:"ArrowLeft",keyCap:"[ArrowLeft]",normal:l(k("\x1b[D","\x1bOD")),ctrl:DEFAULT,alt:DEFAULT,meta:DEFAULT}),m({keyCode:144,code:"NumLock",keyCap:"[NumLock]",normal:PASS,ctrl:PASS,alt:PASS,meta:PASS}),m({keyCode:12,code:"",keyCap:"[Clear]",normal:PASS,ctrl:PASS,alt:PASS,meta:PASS}),m({keyCode:96,code:"Numpad0",keyCap:"[Numpad0]",normal:DEFAULT,ctrl:DEFAULT,alt:DEFAULT,meta:DEFAULT}),m({keyCode:97,code:"Numpad1",keyCap:"[Numpad1]",normal:DEFAULT,ctrl:DEFAULT,alt:DEFAULT,meta:DEFAULT}),m({keyCode:98,code:"Numpad2",keyCap:"[Numpad2]",normal:DEFAULT,ctrl:DEFAULT,alt:DEFAULT,meta:DEFAULT}),m({keyCode:99,code:"Numpad3",keyCap:"[Numpad3]",normal:DEFAULT,ctrl:DEFAULT,alt:DEFAULT,meta:DEFAULT}),m({keyCode:100,code:"Numpad4",keyCap:"[Numpad4]",normal:DEFAULT,ctrl:DEFAULT,alt:DEFAULT,meta:DEFAULT}),m({keyCode:101,code:"Numpad5",keyCap:"[Numpad5]",normal:DEFAULT,ctrl:DEFAULT,alt:DEFAULT,meta:DEFAULT}),m({keyCode:102,code:"Numpad6",keyCap:"[Numpad6]",normal:DEFAULT,ctrl:DEFAULT,alt:DEFAULT,meta:DEFAULT}),m({keyCode:103,code:"Numpad7",keyCap:"[Numpad7]",normal:DEFAULT,ctrl:DEFAULT,alt:DEFAULT,meta:DEFAULT}),m({keyCode:104,code:"Numpad8",keyCap:"[Numpad8]",normal:DEFAULT,ctrl:DEFAULT,alt:DEFAULT,meta:DEFAULT}),m({keyCode:105,code:"Numpad9",keyCap:"[Numpad9]",normal:DEFAULT,ctrl:DEFAULT,alt:DEFAULT,meta:DEFAULT}),m({keyCode:107,code:"NumpadAdd",keyCap:"[NumpadAdd]",normal:DEFAULT,ctrl:DEFAULT,alt:DEFAULT,meta:DEFAULT}),m({keyCode:109,code:"NumpadSubtract",keyCap:"[NumpadSubtract]",normal:DEFAULT,ctrl:DEFAULT,alt:DEFAULT,meta:DEFAULT}),m({keyCode:106,code:"NumpadMultiply",keyCap:"[NumpadMultiply]",normal:DEFAULT,ctrl:DEFAULT,alt:DEFAULT,meta:DEFAULT}),m({keyCode:111,code:"NumpadDivide",keyCap:"[NumpadDivide]",normal:DEFAULT,ctrl:DEFAULT,alt:DEFAULT,meta:DEFAULT}),m({keyCode:110,code:"NumpadDecimal",keyCap:"[NumpadDecimal]",normal:DEFAULT,ctrl:DEFAULT,alt:DEFAULT,meta:DEFAULT}),this._reverseDefs.Backqoute=this._defs[192],this._reverseDefs.BracketLeft=this._defs[229],this._reverseDefs.BracketRight=this._defs[221],this._reverseDefs.Slash=this._defs[191],this._reverseDefs.Space=this._defs[32]}keyCode(p1){let q=this._reverseDefs[p1];return q?q.keyCode:0}key(r1){let s=this._defs[r1];if(!s)return"";let t=s.keyCap;return/^\[\w+\]$/.test(t)?t.substr(1,t.length-2):t.substr(0,1)}code(u1){let v=this._defs[u1];return v?v.code:""}constructor(w1){this._defs={},this._reverseDefs={},this._onCtrlNum=(a,b)=>{switch(b.keyCap.substr(0,1)){case"1":return"1";case"2":return ctl("@");case"3":return ctl("[");case"4":return ctl("\\");case"5":return ctl("]");case"6":return ctl("^");case"7":return ctl("_");case"8":return"\x7f";case"9":return"9";default:return PASS}},this._onAltNum=(a,b)=>DEFAULT,this._onSel=(a2,b)=>{let{ArrowDown:c,ArrowLeft:d,ArrowRight:e,ArrowUp:f,Escape:g,h:h,j:i,k:j,l:k,o:l,b:m,f:n,n:o,p:p,w:q,x:r,y:s}=this._reverseDefs;const t=a=>op("selection",a),u={command:"copy"};if(b===d||b===h){let v=a2.shift?"word":"character";t({dir:"left",gran:v})}else if(b===e||b===k){let w=a2.shift?"word":"character";t({dir:"right",gran:w})}else b===f||b===j?t({dir:"left",gran:"line"}):b===c||b===i?t({dir:"right",gran:"line"}):b===l||b===r?t({command:"change"}):b===o&&a2.ctrl?t({dir:"right",gran:"line"}):b===p?a2.ctrl?t({dir:"left",gran:"line"}):a2.shift||a2.alt||a2.meta||t({command:"paste"}):b===m?a2.ctrl?t({dir:"left",gran:"character"}):(a2.alt,t({dir:"left",gran:"word"})):b===q?t(a2.alt?u:{dir:"right",gran:"word"}):b===n?a2.ctrl?t({dir:"right",gran:"character"}):a2.alt&&t({dir:"right",gran:"word"}):b===s?t(u):b===g&&t({command:"cancel"});return CANCEL},this._keyboard=w1,this.reset()}}function blink_uio_activate(){let b1=new TextDecoder();var c2=0,d2=!1,e1=!1;let f1=/^[a-z0-9\[\]\{\}~`\#\$-_=\+\|"|'\?]$/i;var g=0;let h1=new KeyMap(null);function i(a,b=document.activeElement){if(b){const[c,d]=[b.selectionStart,b.selectionEnd];b.setRangeText(a,c,d,"end"),b.dispatchEvent(new InputEvent("input"))}}function j1(a,b){var c;let d=_objectSpread({},b,{op:a});null===(c=window.webkit.messageHandlers._kb)|| void 0===c||c.postMessage(d)}function k1(a){var b;if(0===a.indexOf("0:0:0:")){i(a.replace("0:0:0:",""));return}var c=a.split(/:/g);let k=parseInt(c[0],10),l=parseInt(c[1],10),m=""==c[3]?":":c[3]||h1.key(l)||"",n=h1.code(l);if(4==c.length&&!k&&f1.test(m)){i(m);return}let o=new KeyboardEvent("keydown",{bubbles:!0,cancelable:!0,composed:!1,isComposing:!1,keyCode:l,key:m,code:n,metaKey:(1048576&k)==1048576,ctrlKey:(262144&k)==262144,altKey:(524288&k)==524288,shiftKey:d2||e1});g=0,null===(b=document.activeElement)|| void 0===b||b.dispatchEvent(o),j1("out",{data:""})}function l1(a){if(a.target===document.activeElement){var b,h=!1;if("ShiftLeft"===a.code?(d2=!0,h=!0):"ShiftRight"===a.code&&(e1=!0,h=!0),!a.__blink){let k=a.altKey||a.ctrlKey||a.shiftKey||a.metaKey;if(a.__noskip||k);else if(0===c2){a.repeat&&f1.test(a.key)?1==(g+=1)?i(a.key,document.activeElement):g%=2:g=0;return}if(0!==c2){let l=new KeyboardEvent("keydown",{bubbles:a.bubbles,cancelable:a.cancelable,composed:a.composed,key:a.key,code:a.code,location:a.location,repeat:a.repeat,ctrlKey:(262144&c2)==262144,shiftKey:a.shiftKey,altKey:(524288&c2)==524288,metaKey:(1048576&c2)==1048576,isComposing:a.isComposing,charCode:a.charCode,keyCode:a.keyCode,which:a.which});l.__blink=!0,a.preventDefault(),a.stopPropagation(),null===(b=document.activeElement)|| void 0===b||b.dispatchEvent(l)}h||j1("out",{data:""})}}}function m1(a){a.target===document.activeElement&&("ShiftLeft"===a.code?d2=!1:"ShiftRight"===a.code&&(e1=!1))}function n1(a){!!a&&(a.__blink||(a.tabIndex=-1,a.__blink=!0,a.autocapitalize="none",a.autocorrect=!1,a!==document.body&&(a.addEventListener("keydown",l1,!0),a.addEventListener("keyup",m1,!0))))}window.term_paste=function(a){i(a)},window.term_getCurrentSelection=function(){const a=document.getSelection();if(!a||0===a.rangeCount||"Caret"===a.type)return{base:"",offset:0,text:""};const b=a.getRangeAt(0).getBoundingClientRect(),c=`{{${b.x}, ${b.y}},{${b.width},${b.height}}}`;return{base:a.baseNode.textContent,offset:a.baseOffset,text:a.toString()||"",rect:c}},document.addEventListener("selectionchange",function(){var a,b;a=term_getCurrentSelection(),null===(b=window.webkit.messageHandlers.interOp)|| void 0===b||b.postMessage({op:"selectionchange",data:a})}),window._onKB=function(a3,f2){switch(a3){case"state-reset":c2=0,d2=!1,e1=!1,g=0;break;case"toolbar-mods":var h,j;c2=h=f2,(j=document.activeElement)&&(0==h?j.readOnly=j.__readOnly:j.__readOnly=j.readOnly);break;case"toolbar-press":k1(f2);break;case"press":k1(f2);break;case"hex":i(function(a){let c=a.length,d=new Uint8Array(c/2);for(var e=0,f=0;f{var b=0;const c=setInterval(()=>{b+=1,document.getElementsByClassName("monaco-workbench").length>0&&(__install_uio(),clearInterval(c)),b>22000&&clearInterval(c)},50)}) \ No newline at end of file diff --git a/Resources/blinkCommandsDictionary.plist b/Resources/blinkCommandsDictionary.plist index 1a608cac0..6929877a7 100644 --- a/Resources/blinkCommandsDictionary.plist +++ b/Resources/blinkCommandsDictionary.plist @@ -2,6 +2,13 @@ + mosh1 + + MAIN + blink_mosh_main + + no + skstore MAIN @@ -135,13 +142,6 @@ file - link-files - - MAIN - link_files_main - - no - udptunnel MAIN diff --git a/Resources/commandDictionary.plist b/Resources/commandDictionary.plist index 61ebe6eaa..aa117fcbe 100644 --- a/Resources/commandDictionary.plist +++ b/Resources/commandDictionary.plist @@ -9,6 +9,13 @@ dhiflM:N:nsuw file + bc + + bc_ios.framework/bc_ios + bcdc_main + e:f:hilqsvVwx + no + cat text.framework/text @@ -53,8 +60,8 @@ curl - MAIN - curl_static_main + curl_ios.framework/curl_ios + curl_main 2346aAbBcCdDeEfgGhHiIjJkKlLmMnNoOpPqQrRsStTuUvVwxXYyz# file @@ -93,13 +100,6 @@ n no - ed - - text.framework/text - ed_main - p:sx - file - egrep text.framework/text @@ -170,6 +170,13 @@ abdlmruv no + less + + files.framework/files + less_main + + no + link files.framework/files @@ -247,6 +254,13 @@ AaB:b:Cc:DdfG:g:h:I:i:k:K:Ll:M:m:noP:p:QqRrS:s:T:t:vW:z: no + ping6 + + network_ios.framework/network_ios + ping6_main + DdfHnNoqrRtvwWa:b:c:g:h:I:i:l:p:S:s:z:k:K: + no + printenv shell.framework/shell @@ -289,13 +303,6 @@ p directory - scp - - MAIN - curl_static_main - q - file - sed text.framework/text @@ -310,27 +317,6 @@ no - sftp - - MAIN - curl_static_main - q - file - - ssh - - ssh_cmd.framework/ssh_cmd - ssh_main - q - - - ssh-keygen - - ssh_cmd.framework/ssh_cmd - sshkeygen_main - ABHLQXceghiklopquvxyC:D:E:F:G:I:J:K:M:N:O:P:R:S:T:V:W:Z:a:b:f:g:j:m:n:r:s:t:z: - file - sort text.framework/text @@ -429,6 +415,13 @@ no + vim + + vim.framework/vim + main + + + uptime shell.framework/shell @@ -464,12 +457,12 @@ 0E:I:J:L:n:oP:pR:s:tx no - wol - - network_ios.framework/network_ios - _Z8wol_mainiPKPc - hqb:p:d: - no - + wol + + network_ios.framework/network_ios + _Z8wol_mainiPKPc + hqb:p:d: + no + diff --git a/Resources/hterm_all.min.js b/Resources/hterm_all.min.js index 0e4ed1816..6f0160447 100644 --- a/Resources/hterm_all.min.js +++ b/Resources/hterm_all.min.js @@ -11,7 +11,7 @@ object-assign * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. - */var o=r(2),n=r(0),i=r(8),s=r(3),a=r(5),l=r(9),c=r(10),u=r(11),h=r(4);function d(e){for(var t=arguments.length-1,r="https://reactjs.org/docs/error-decoder.html?invariant="+e,n=0;nthis.eventPool.length&&this.eventPool.push(e)}function Te(e){e.eventPool=[],e.getPooled=Se,e.release=ke}s(Ce.prototype,{preventDefault:function(){this.defaultPrevented=!0;var e=this.nativeEvent;e&&(e.preventDefault?e.preventDefault():"unknown"!=typeof e.returnValue&&(e.returnValue=!1),this.isDefaultPrevented=a.thatReturnsTrue)},stopPropagation:function(){var e=this.nativeEvent;e&&(e.stopPropagation?e.stopPropagation():"unknown"!=typeof e.cancelBubble&&(e.cancelBubble=!0),this.isPropagationStopped=a.thatReturnsTrue)},persist:function(){this.isPersistent=a.thatReturnsTrue},isPersistent:a.thatReturnsFalse,destructor:function(){var e,t=this.constructor.Interface;for(e in t)this[e]=null;for(t=0;t=Ee),Ne=String.fromCharCode(32),Ie={beforeInput:{phasedRegistrationNames:{bubbled:"onBeforeInput",captured:"onBeforeInputCapture"},dependencies:["compositionend","keypress","textInput","paste"]},compositionEnd:{phasedRegistrationNames:{bubbled:"onCompositionEnd",captured:"onCompositionEndCapture"},dependencies:"blur compositionend keydown keypress keyup mousedown".split(" ")},compositionStart:{phasedRegistrationNames:{bubbled:"onCompositionStart",captured:"onCompositionStartCapture"},dependencies:"blur compositionstart keydown keypress keyup mousedown".split(" ")},compositionUpdate:{phasedRegistrationNames:{bubbled:"onCompositionUpdate",captured:"onCompositionUpdateCapture"},dependencies:"blur compositionupdate keydown keypress keyup mousedown".split(" ")}},De=!1;function Oe(e,t){switch(e){case"keyup":return-1!==Ae.indexOf(t.keyCode);case"keydown":return 229!==t.keyCode;case"keypress":case"mousedown":case"blur":return!0;default:return!1}}function Ve(e){return"object"==typeof(e=e.detail)&&"data"in e?e.data:null}var Be=!1;var Le={eventTypes:Ie,extractEvents:function(e,t,r,o){var n=void 0,i=void 0;if(Re)e:{switch(e){case"compositionstart":n=Ie.compositionStart;break e;case"compositionend":n=Ie.compositionEnd;break e;case"compositionupdate":n=Ie.compositionUpdate;break e}n=void 0}else Be?Oe(e,r)&&(n=Ie.compositionEnd):"keydown"===e&&229===r.keyCode&&(n=Ie.compositionStart);return n?(Me&&(Be||n!==Ie.compositionStart?n===Ie.compositionEnd&&Be&&(i=_e()):(be._root=o,be._startText=ye(),Be=!0)),n=Pe.getPooled(n,t,r,o),i?n.data=i:null!==(i=Ve(r))&&(n.data=i),re(n),i=n):i=null,(e=Fe?function(e,t){switch(e){case"compositionend":return Ve(t);case"keypress":return 32!==t.which?null:(De=!0,Ne);case"textInput":return(e=t.data)===Ne&&De?null:e;default:return null}}(e,r):function(e,t){if(Be)return"compositionend"===e||!Re&&Oe(e,t)?(e=_e(),be._root=null,be._startText=null,be._fallbackText=null,Be=!1,e):null;switch(e){case"paste":return null;case"keypress":if(!(t.ctrlKey||t.altKey||t.metaKey)||t.ctrlKey&&t.altKey){if(t.char&&1