Remove Multiplatform targets

This commit is contained in:
Maurice Parker
2021-10-15 17:23:40 -05:00
parent 3d5bdb44fb
commit 23fe288fe9
239 changed files with 0 additions and 18881 deletions

View File

@@ -1,285 +0,0 @@
//
// AppDelegate.swift
// Multiplatform macOS
//
// Created by Maurice Parker on 6/28/20.
// Copyright © 2020 Ranchero Software. All rights reserved.
//
import AppKit
import os.log
import UserNotifications
import Articles
import RSWeb
import Account
import RSCore
import Secrets
// If we're not going to import Sparkle, provide dummy protocols to make it easy
// for AppDelegate to comply
#if MAC_APP_STORE || TEST
protocol SPUStandardUserDriverDelegate {}
protocol SPUUpdaterDelegate {}
#else
import Sparkle
#endif
var appDelegate: AppDelegate!
class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDelegate, UnreadCountProvider, SPUStandardUserDriverDelegate, SPUUpdaterDelegate
{
private var log = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "Application")
var userNotificationManager: UserNotificationManager!
var faviconDownloader: FaviconDownloader!
var imageDownloader: ImageDownloader!
var authorAvatarDownloader: AuthorAvatarDownloader!
var webFeedIconDownloader: WebFeedIconDownloader!
var refreshTimer: AccountRefreshTimer?
var syncTimer: ArticleStatusSyncTimer?
var shuttingDown = false {
didSet {
if shuttingDown {
refreshTimer?.shuttingDown = shuttingDown
refreshTimer?.invalidate()
syncTimer?.shuttingDown = shuttingDown
syncTimer?.invalidate()
}
}
}
var unreadCount = 0 {
didSet {
if unreadCount != oldValue {
CoalescingQueue.standard.add(self, #selector(updateDockBadge))
postUnreadCountDidChangeNotification()
}
}
}
var appName: String!
private let appMovementMonitor = RSAppMovementMonitor()
#if !MAC_APP_STORE && !TEST
var softwareUpdater: SPUUpdater!
#endif
override init() {
super.init()
SecretsManager.provider = Secrets()
AccountManager.shared = AccountManager(accountsFolder: Platform.dataSubfolder(forApplication: nil, folderName: "Accounts")!)
ArticleThemesManager.shared = ArticleThemesManager(folderPath: Platform.dataSubfolder(forApplication: nil, folderName: "Themes")!)
FeedProviderManager.shared.delegate = ExtensionPointManager.shared
NotificationCenter.default.addObserver(self, selector: #selector(unreadCountDidChange(_:)), name: .UnreadCountDidChange, object: nil)
NSWorkspace.shared.notificationCenter.addObserver(self, selector: #selector(didWakeNotification(_:)), name: NSWorkspace.didWakeNotification, object: nil)
appDelegate = self
}
// MARK: - NSApplicationDelegate
func applicationWillFinishLaunching(_ notification: Notification) {
// TODO: add Apple Events back in
// installAppleEventHandlers()
CacheCleaner.purgeIfNecessary()
// Try to establish a cache in the Caches folder, but if it fails for some reason fall back to a temporary dir
let cacheFolder: String
if let userCacheFolder = try? FileManager.default.url(for: .cachesDirectory, in: .userDomainMask, appropriateFor: nil, create: false).path {
cacheFolder = userCacheFolder
}
else {
let bundleIdentifier = (Bundle.main.infoDictionary!["CFBundleIdentifier"]! as! String)
cacheFolder = (NSTemporaryDirectory() as NSString).appendingPathComponent(bundleIdentifier)
}
let faviconsFolder = (cacheFolder as NSString).appendingPathComponent("Favicons")
let faviconsFolderURL = URL(fileURLWithPath: faviconsFolder)
try! FileManager.default.createDirectory(at: faviconsFolderURL, withIntermediateDirectories: true, attributes: nil)
faviconDownloader = FaviconDownloader(folder: faviconsFolder)
let imagesFolder = (cacheFolder as NSString).appendingPathComponent("Images")
let imagesFolderURL = URL(fileURLWithPath: imagesFolder)
try! FileManager.default.createDirectory(at: imagesFolderURL, withIntermediateDirectories: true, attributes: nil)
imageDownloader = ImageDownloader(folder: imagesFolder)
authorAvatarDownloader = AuthorAvatarDownloader(imageDownloader: imageDownloader)
webFeedIconDownloader = WebFeedIconDownloader(imageDownloader: imageDownloader, folder: cacheFolder)
appName = (Bundle.main.infoDictionary!["CFBundleExecutable"]! as! String)
}
func applicationDidFinishLaunching(_ note: Notification) {
#if MAC_APP_STORE || TEST
checkForUpdatesMenuItem.isHidden = true
#else
// Initialize Sparkle...
let hostBundle = Bundle.main
let updateDriver = SPUStandardUserDriver(hostBundle: hostBundle, delegate: self)
self.softwareUpdater = SPUUpdater(hostBundle: hostBundle, applicationBundle: hostBundle, userDriver: updateDriver, delegate: self)
do {
try self.softwareUpdater.start()
}
catch {
NSLog("Failed to start software updater with error: \(error)")
}
#endif
AppDefaults.registerDefaults()
let isFirstRun = AppDefaults.shared.isFirstRun()
if isFirstRun {
os_log(.debug, log: log, "Is first run.")
}
let localAccount = AccountManager.shared.defaultAccount
if isFirstRun && !AccountManager.shared.anyAccountHasAtLeastOneFeed() {
DefaultFeedsImporter.importDefaultFeeds(account: localAccount)
}
NotificationCenter.default.addObserver(self, selector: #selector(webFeedSettingDidChange(_:)), name: .WebFeedSettingDidChange, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(userDefaultsDidChange(_:)), name: UserDefaults.didChangeNotification, object: nil)
DispatchQueue.main.async {
self.unreadCount = AccountManager.shared.unreadCount
}
refreshTimer = AccountRefreshTimer()
syncTimer = ArticleStatusSyncTimer()
NSApplication.shared.registerForRemoteNotifications()
UNUserNotificationCenter.current().delegate = self
userNotificationManager = UserNotificationManager()
// TODO: Add a debug menu
// if AppDefaults.showDebugMenu {
// refreshTimer!.update()
// syncTimer!.update()
//
// // The Web Inspector uses SPI and can never appear in a MAC_APP_STORE build.
// #if MAC_APP_STORE
// let debugMenu = debugMenuItem.submenu!
// let toggleWebInspectorItemIndex = debugMenu.indexOfItem(withTarget: self, andAction: #selector(toggleWebInspectorEnabled(_:)))
// if toggleWebInspectorItemIndex != -1 {
// debugMenu.removeItem(at: toggleWebInspectorItemIndex)
// }
// #endif
// } else {
// debugMenuItem.menu?.removeItem(debugMenuItem)
// DispatchQueue.main.async {
// self.refreshTimer!.timedRefresh(nil)
// self.syncTimer!.timedRefresh(nil)
// }
// }
// TODO: Add back in crash reporter
// #if !MAC_APP_STORE
// DispatchQueue.main.async {
// CrashReporter.check(appName: "NetNewsWire")
// }
// #endif
}
func applicationDidBecomeActive(_ notification: Notification) {
fireOldTimers()
}
func applicationDidResignActive(_ notification: Notification) {
ArticleStringFormatter.emptyCaches()
}
func application(_ application: NSApplication, didReceiveRemoteNotification userInfo: [String : Any]) {
AccountManager.shared.receiveRemoteNotification(userInfo: userInfo)
}
func applicationWillTerminate(_ notification: Notification) {
shuttingDown = true
}
// MARK: Notifications
@objc func unreadCountDidChange(_ note: Notification) {
if note.object is AccountManager {
unreadCount = AccountManager.shared.unreadCount
}
}
@objc func webFeedSettingDidChange(_ note: Notification) {
guard let feed = note.object as? WebFeed, let key = note.userInfo?[WebFeed.WebFeedSettingUserInfoKey] as? String else {
return
}
if key == WebFeed.WebFeedSettingKey.homePageURL || key == WebFeed.WebFeedSettingKey.faviconURL {
let _ = faviconDownloader.favicon(for: feed)
}
}
@objc func userDefaultsDidChange(_ note: Notification) {
refreshTimer?.update()
updateDockBadge()
}
@objc func didWakeNotification(_ note: Notification) {
fireOldTimers()
}
// MARK: UNUserNotificationCenterDelegate
func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
completionHandler([.banner, .badge, .sound])
}
func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) {
// TODO: Add back in Notification handling
// mainWindowController?.handle(response)
completionHandler()
}
// MARK: - Dock Badge
@objc func updateDockBadge() {
let label = unreadCount > 0 && !AppDefaults.shared.hideDockUnreadCount ? "\(unreadCount)" : ""
NSApplication.shared.dockTile.badgeLabel = label
}
}
private extension AppDelegate {
func fireOldTimers() {
// Its possible theres a refresh timer set to go off in the past.
// In that case, refresh now and update the timer.
refreshTimer?.fireOldTimer()
syncTimer?.fireOldTimer()
}
}
/*
the ScriptingAppDelegate protocol exposes a narrow set of accessors with
internal visibility which are very similar to some private vars.
These would be unnecessary if the similar accessors were marked internal rather than private,
but for now, we'll keep the stratification of visibility
*/
//extension AppDelegate : ScriptingAppDelegate {
//
// internal var scriptingMainWindowController: ScriptingMainWindowController? {
// return mainWindowController
// }
//
// internal var scriptingCurrentArticle: Article? {
// return self.scriptingMainWindowController?.scriptingCurrentArticle
// }
//
// internal var scriptingSelectedArticles: [Article] {
// return self.scriptingMainWindowController?.scriptingSelectedArticles ?? []
// }
//}

View File

@@ -1,25 +0,0 @@
//
// ArticleView.swift
// Multiplatform macOS
//
// Created by Maurice Parker on 7/8/20.
// Copyright © 2020 Ranchero Software. All rights reserved.
//
import SwiftUI
import Articles
struct ArticleView: NSViewControllerRepresentable {
@EnvironmentObject private var sceneModel: SceneModel
func makeNSViewController(context: Context) -> WebViewController {
let controller = WebViewController()
controller.sceneModel = sceneModel
return controller
}
func updateNSViewController(_ controller: WebViewController, context: Context) {
}
}

View File

@@ -1,134 +0,0 @@
//
// IconView.swift
// Multiplatform macOS
//
// Created by Maurice Parker on 7/7/20.
// Copyright © 2020 Ranchero Software. All rights reserved.
//
import AppKit
final class IconView: NSView {
var iconImage: IconImage? = nil {
didSet {
if iconImage !== oldValue {
imageView.image = iconImage?.image
if NSApplication.shared.effectiveAppearance.isDarkMode {
if self.iconImage?.isDark ?? false {
self.isDiscernable = false
} else {
self.isDiscernable = true
}
} else {
if self.iconImage?.isBright ?? false {
self.isDiscernable = false
} else {
self.isDiscernable = true
}
}
needsDisplay = true
needsLayout = true
}
}
}
private var isDiscernable = true
override var isFlipped: Bool {
return true
}
private let imageView: NSImageView = {
let imageView = NSImageView(frame: NSRect.zero)
imageView.animates = false
imageView.imageAlignment = .alignCenter
imageView.imageScaling = .scaleProportionallyUpOrDown
return imageView
}()
private var hasExposedVerticalBackground: Bool {
return imageView.frame.size.height < bounds.size.height
}
override init(frame frameRect: NSRect) {
super.init(frame: frameRect)
commonInit()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
commonInit()
}
convenience init() {
self.init(frame: NSRect.zero)
}
override func viewDidMoveToSuperview() {
needsLayout = true
needsDisplay = true
}
override func layout() {
resizeSubviews(withOldSize: NSZeroSize)
}
override func resizeSubviews(withOldSize oldSize: NSSize) {
imageView.setFrame(ifNotEqualTo: rectForImageView())
}
override func draw(_ dirtyRect: NSRect) {
guard hasExposedVerticalBackground || !isDiscernable else {
return
}
let color = AppAssets.nsIconBackgroundColor
color.set()
dirtyRect.fill()
}
}
private extension IconView {
func commonInit() {
addSubview(imageView)
wantsLayer = true
layer?.cornerRadius = 4.0
}
func rectForImageView() -> NSRect {
guard !(iconImage?.isSymbol ?? false) else {
return NSMakeRect(0.0, 0.0, bounds.size.width, bounds.size.height)
}
guard let image = iconImage?.image else {
return NSRect.zero
}
let imageSize = image.size
let viewSize = bounds.size
if imageSize.height == imageSize.width {
if imageSize.height >= viewSize.height {
return NSMakeRect(0.0, 0.0, viewSize.width, viewSize.height)
}
let offset = floor((viewSize.height - imageSize.height) / 2.0)
return NSMakeRect(offset, offset, imageSize.width, imageSize.height)
}
else if imageSize.height > imageSize.width {
let factor = viewSize.height / imageSize.height
let width = imageSize.width * factor
let originX = floor((viewSize.width - width) / 2.0)
return NSMakeRect(originX, 0.0, width, viewSize.height)
}
// Wider than tall: imageSize.width > imageSize.height
let factor = viewSize.width / imageSize.width
let height = imageSize.height * factor
let originY = floor((viewSize.height - height) / 2.0)
return NSMakeRect(0.0, originY, viewSize.width, height)
}
}

View File

@@ -1,32 +0,0 @@
//
// SharingServiceDelegate.swift
// NetNewsWire
//
// Created by Maurice Parker on 9/7/18.
// Copyright © 2018 Ranchero Software. All rights reserved.
//
import AppKit
@objc final class SharingServiceDelegate: NSObject, NSSharingServiceDelegate {
weak var window: NSWindow?
init(_ window: NSWindow?) {
self.window = window
}
func sharingService(_ sharingService: NSSharingService, willShareItems items: [Any]) {
sharingService.subject = items
.compactMap { item in
let writer = item as? ArticlePasteboardWriter
return writer?.article.title
}
.joined(separator: ", ")
}
func sharingService(_ sharingService: NSSharingService, sourceWindowForShareItems items: [Any], sharingContentScope: UnsafeMutablePointer<NSSharingService.SharingContentScope>) -> NSWindow? {
return window
}
}

View File

@@ -1,55 +0,0 @@
//
// SharingServicePickerDelegate.swift
// NetNewsWire
//
// Created by Brent Simmons on 2/17/18.
// Copyright © 2018 Ranchero Software. All rights reserved.
//
import AppKit
import RSCore
@objc final class SharingServicePickerDelegate: NSObject, NSSharingServicePickerDelegate {
private let sharingServiceDelegate: SharingServiceDelegate
private let completion: (() -> Void)?
init(_ window: NSWindow?, completion: (() -> Void)?) {
self.sharingServiceDelegate = SharingServiceDelegate(window)
self.completion = completion
}
func sharingServicePicker(_ sharingServicePicker: NSSharingServicePicker, sharingServicesForItems items: [Any], proposedSharingServices proposedServices: [NSSharingService]) -> [NSSharingService] {
let filteredServices = proposedServices.filter { $0.menuItemTitle != "NetNewsWire" }
return filteredServices + SharingServicePickerDelegate.customSharingServices(for: items)
}
func sharingServicePicker(_ sharingServicePicker: NSSharingServicePicker, delegateFor sharingService: NSSharingService) -> NSSharingServiceDelegate? {
return sharingServiceDelegate
}
func sharingServicePicker(_ sharingServicePicker: NSSharingServicePicker, didChoose service: NSSharingService?) {
completion?()
}
static func customSharingServices(for items: [Any]) -> [NSSharingService] {
let customServices = ExtensionPointManager.shared.activeSendToCommands.compactMap { (sendToCommand) -> NSSharingService? in
guard let object = items.first else {
return nil
}
guard sendToCommand.canSendObject(object, selectedText: nil) else {
return nil
}
let image = sendToCommand.image ?? NSImage()
return NSSharingService(title: sendToCommand.title, image: image, alternateImage: nil) {
sendToCommand.sendObject(object, selectedText: nil)
}
}
return customServices
}
}

View File

@@ -1,56 +0,0 @@
//
// SharingServiceView.swift
// Multiplatform macOS
//
// Created by Maurice Parker on 7/14/20.
// Copyright © 2020 Ranchero Software. All rights reserved.
//
import SwiftUI
import AppKit
import Articles
class SharingServiceController: NSViewController {
var sharingServicePickerDelegate: SharingServicePickerDelegate? = nil
var articles = [Article]()
var completion: (() -> Void)? = nil
override func loadView() {
view = NSView()
}
override func viewDidAppear() {
guard let anchor = view.superview?.superview else { return }
sharingServicePickerDelegate = SharingServicePickerDelegate(self.view.window, completion: completion)
let sortedArticles = articles.sortedByDate(.orderedAscending)
let items = sortedArticles.map { ArticlePasteboardWriter(article: $0) }
let sharingServicePicker = NSSharingServicePicker(items: items)
sharingServicePicker.delegate = sharingServicePickerDelegate
sharingServicePicker.show(relativeTo: anchor.bounds, of: anchor, preferredEdge: .minY)
}
}
struct SharingServiceView: NSViewControllerRepresentable {
var articles: [Article]
@Binding var showing: Bool
func makeNSViewController(context: Context) -> SharingServiceController {
let controller = SharingServiceController()
controller.articles = articles
controller.completion = {
showing = false
}
return controller
}
func updateNSViewController(_ nsViewController: SharingServiceController, context: Context) {
}
}

View File

@@ -1,101 +0,0 @@
//
// WebStatusBarView.swift
// Multiplatform macOS
//
// Created by Maurice Parker on 7/8/20.
// Copyright © 2020 Ranchero Software. All rights reserved.
//
import AppKit
import Articles
final class WebStatusBarView: NSView {
var urlLabel = NSTextField(labelWithString: "")
var mouseoverLink: String? {
didSet {
updateLinkForDisplay()
}
}
private var linkForDisplay: String? {
didSet {
needsLayout = true
if let link = linkForDisplay {
urlLabel.stringValue = link
self.isHidden = false
}
else {
urlLabel.stringValue = ""
self.isHidden = true
}
}
}
private var didConfigureLayerRadius = false
override var isOpaque: Bool {
return false
}
override var isFlipped: Bool {
return true
}
override var wantsUpdateLayer: Bool {
return true
}
override init(frame frameRect: NSRect) {
super.init(frame: frameRect)
commonInit()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
commonInit()
}
override func updateLayer() {
guard let layer = layer else {
return
}
if !didConfigureLayerRadius {
layer.cornerRadius = 4.0
didConfigureLayerRadius = true
}
layer.backgroundColor = AppAssets.webStatusBarBackground.cgColor
}
}
// MARK: - Private
private extension WebStatusBarView {
func commonInit() {
self.isHidden = true
urlLabel.translatesAutoresizingMaskIntoConstraints = false
urlLabel.lineBreakMode = .byTruncatingMiddle
urlLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
addSubview(urlLabel)
NSLayoutConstraint.activate([
leadingAnchor.constraint(equalTo: urlLabel.leadingAnchor, constant: -6),
trailingAnchor.constraint(equalTo: urlLabel.trailingAnchor, constant: 6),
centerYAnchor.constraint(equalTo: urlLabel.centerYAnchor)
])
}
func updateLinkForDisplay() {
if let mouseoverLink = mouseoverLink, !mouseoverLink.isEmpty {
linkForDisplay = mouseoverLink.strippingHTTPOrHTTPSScheme
}
else {
linkForDisplay = nil
}
}
}

View File

@@ -1,373 +0,0 @@
//
// WebViewController.swift
// Multiplatform macOS
//
// Created by Maurice Parker on 7/8/20.
// Copyright © 2020 Ranchero Software. All rights reserved.
//
import AppKit
import Combine
import RSCore
import Articles
protocol WebViewControllerDelegate: AnyObject {
func webViewController(_: WebViewController, articleExtractorButtonStateDidUpdate: ArticleExtractorButtonState)
}
class WebViewController: NSViewController {
private struct MessageName {
static let imageWasClicked = "imageWasClicked"
static let imageWasShown = "imageWasShown"
static let mouseDidEnter = "mouseDidEnter"
static let mouseDidExit = "mouseDidExit"
static let showFeedInspector = "showFeedInspector"
}
var statusBarView: WebStatusBarView!
private var webView: PreloadedWebView?
private var articleExtractor: ArticleExtractor? = nil
var extractedArticle: ExtractedArticle?
var isShowingExtractedArticle = false
var articleExtractorButtonState: ArticleExtractorButtonState = .off {
didSet {
delegate?.webViewController(self, articleExtractorButtonStateDidUpdate: articleExtractorButtonState)
}
}
var sceneModel: SceneModel?
weak var delegate: WebViewControllerDelegate?
var articles: [Article]? {
didSet {
if oldValue != articles {
loadWebView()
}
}
}
private var cancellables = Set<AnyCancellable>()
override func loadView() {
view = NSView()
}
override func viewDidLoad() {
super.viewDidLoad()
NotificationCenter.default.addObserver(self, selector: #selector(webFeedIconDidBecomeAvailable(_:)), name: .WebFeedIconDidBecomeAvailable, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(avatarDidBecomeAvailable(_:)), name: .AvatarDidBecomeAvailable, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(faviconDidBecomeAvailable(_:)), name: .FaviconDidBecomeAvailable, object: nil)
statusBarView = WebStatusBarView()
statusBarView.translatesAutoresizingMaskIntoConstraints = false
self.view.addSubview(statusBarView)
NSLayoutConstraint.activate([
self.view.leadingAnchor.constraint(equalTo: statusBarView.leadingAnchor, constant: -6),
self.view.trailingAnchor.constraint(greaterThanOrEqualTo: statusBarView.trailingAnchor, constant: 6),
self.view.bottomAnchor.constraint(equalTo: statusBarView.bottomAnchor, constant: 2),
statusBarView.heightAnchor.constraint(equalToConstant: 20)
])
sceneModel?.timelineModel.selectedArticlesPublisher?.sink { [weak self] articles in
self?.articles = articles
}
.store(in: &cancellables)
}
// MARK: Notifications
@objc func webFeedIconDidBecomeAvailable(_ note: Notification) {
reloadArticleImage()
}
@objc func avatarDidBecomeAvailable(_ note: Notification) {
reloadArticleImage()
}
@objc func faviconDidBecomeAvailable(_ note: Notification) {
reloadArticleImage()
}
// MARK: API
func focus() {
webView?.becomeFirstResponder()
}
func canScrollDown(_ completion: @escaping (Bool) -> Void) {
fetchScrollInfo { (scrollInfo) in
completion(scrollInfo?.canScrollDown ?? false)
}
}
override func scrollPageDown(_ sender: Any?) {
webView?.scrollPageDown(sender)
}
func toggleArticleExtractor() {
guard let article = articles?.first else {
return
}
guard articleExtractor?.state != .processing else {
stopArticleExtractor()
loadWebView()
return
}
guard !isShowingExtractedArticle else {
isShowingExtractedArticle = false
loadWebView()
articleExtractorButtonState = .off
return
}
if let articleExtractor = articleExtractor {
if article.preferredLink == articleExtractor.articleLink {
isShowingExtractedArticle = true
loadWebView()
articleExtractorButtonState = .on
}
} else {
startArticleExtractor()
}
}
func stopArticleExtractorIfProcessing() {
if articleExtractor?.state == .processing {
stopArticleExtractor()
}
}
func stopWebViewActivity() {
if let webView = webView {
stopMediaPlayback(webView)
}
}
}
// MARK: ArticleExtractorDelegate
extension WebViewController: ArticleExtractorDelegate {
func articleExtractionDidFail(with: Error) {
stopArticleExtractor()
articleExtractorButtonState = .error
loadWebView()
}
func articleExtractionDidComplete(extractedArticle: ExtractedArticle) {
if articleExtractor?.state != .cancelled {
self.extractedArticle = extractedArticle
isShowingExtractedArticle = true
loadWebView()
articleExtractorButtonState = .on
}
}
}
// MARK: WKScriptMessageHandler
extension WebViewController: WKScriptMessageHandler {
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
switch message.name {
case MessageName.imageWasShown:
return
case MessageName.imageWasClicked:
return
case MessageName.mouseDidEnter:
if let link = message.body as? String {
statusBarView.mouseoverLink = link
}
case MessageName.mouseDidExit:
statusBarView.mouseoverLink = nil
case MessageName.showFeedInspector:
return
default:
return
}
}
}
extension WebViewController: WKNavigationDelegate {
public func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
if navigationAction.navigationType == .linkActivated {
if let url = navigationAction.request.url {
let flags = navigationAction.modifierFlags
let invert = flags.contains(.shift) || flags.contains(.command)
Browser.open(url.absoluteString, invertPreference: invert)
}
decisionHandler(.cancel)
return
}
decisionHandler(.allow)
}
}
// MARK: Private
private extension WebViewController {
func loadWebView() {
if let webView = webView {
self.renderPage(webView)
return
}
sceneModel?.webViewProvider?.dequeueWebView() { webView in
webView.ready {
// Add the webview
self.webView = webView
webView.translatesAutoresizingMaskIntoConstraints = false
self.view.addSubview(webView, positioned: .below, relativeTo: self.statusBarView)
NSLayoutConstraint.activate([
self.view.leadingAnchor.constraint(equalTo: webView.leadingAnchor),
self.view.trailingAnchor.constraint(equalTo: webView.trailingAnchor),
self.view.topAnchor.constraint(equalTo: webView.topAnchor),
self.view.bottomAnchor.constraint(equalTo: webView.bottomAnchor)
])
webView.navigationDelegate = self
webView.configuration.userContentController.add(WrapperScriptMessageHandler(self), name: MessageName.imageWasClicked)
webView.configuration.userContentController.add(WrapperScriptMessageHandler(self), name: MessageName.imageWasShown)
webView.configuration.userContentController.add(WrapperScriptMessageHandler(self), name: MessageName.mouseDidEnter)
webView.configuration.userContentController.add(WrapperScriptMessageHandler(self), name: MessageName.mouseDidExit)
webView.configuration.userContentController.add(WrapperScriptMessageHandler(self), name: MessageName.showFeedInspector)
self.renderPage(webView)
}
}
}
func renderPage(_ webView: PreloadedWebView) {
let theme = ArticleThemesManager.shared.currentTheme
let rendering: ArticleRenderer.Rendering
if articles?.count ?? 0 > 1 {
rendering = ArticleRenderer.multipleSelectionHTML(theme: theme)
} else if let articleExtractor = articleExtractor, articleExtractor.state == .processing {
rendering = ArticleRenderer.loadingHTML(theme: theme)
} else if let articleExtractor = articleExtractor, articleExtractor.state == .failedToParse, let article = articles?.first {
rendering = ArticleRenderer.articleHTML(article: article, theme: theme)
} else if let article = articles?.first, let extractedArticle = extractedArticle {
if isShowingExtractedArticle {
rendering = ArticleRenderer.articleHTML(article: article, extractedArticle: extractedArticle, theme: theme)
} else {
rendering = ArticleRenderer.articleHTML(article: article, theme: theme)
}
} else if let article = articles?.first {
rendering = ArticleRenderer.articleHTML(article: article, theme: theme)
} else {
rendering = ArticleRenderer.noSelectionHTML(theme: theme)
}
let substitutions = [
"title": rendering.title,
"baseURL": rendering.baseURL,
"style": rendering.style,
"body": rendering.html
]
let html = try! MacroProcessor.renderedText(withTemplate: ArticleRenderer.page.html, substitutions: substitutions)
webView.loadHTMLString(html, baseURL: ArticleRenderer.page.baseURL)
}
func fetchScrollInfo(_ completion: @escaping (ScrollInfo?) -> Void) {
guard let webView = webView else {
completion(nil)
return
}
let javascriptString = "var x = {contentHeight: document.body.scrollHeight, offsetY: window.pageYOffset}; x"
webView.evaluateJavaScript(javascriptString) { (info, error) in
guard let info = info as? [String: Any] else {
completion(nil)
return
}
guard let contentHeight = info["contentHeight"] as? CGFloat, let offsetY = info["offsetY"] as? CGFloat else {
completion(nil)
return
}
let scrollInfo = ScrollInfo(contentHeight: contentHeight, viewHeight: webView.frame.height, offsetY: offsetY)
completion(scrollInfo)
}
}
func startArticleExtractor() {
if let link = articles?.first?.preferredLink, let extractor = ArticleExtractor(link) {
extractor.delegate = self
extractor.process()
articleExtractor = extractor
articleExtractorButtonState = .animated
}
}
func stopArticleExtractor() {
articleExtractor?.cancel()
articleExtractor = nil
isShowingExtractedArticle = false
articleExtractorButtonState = .off
}
func reloadArticleImage() {
guard let article = articles?.first else { return }
var components = URLComponents()
components.scheme = ArticleRenderer.imageIconScheme
components.path = article.articleID
if let imageSrc = components.string {
webView?.evaluateJavaScript("reloadArticleImage(\"\(imageSrc)\")")
}
}
func stopMediaPlayback(_ webView: WKWebView) {
webView.evaluateJavaScript("stopMediaPlayback();")
}
}
// MARK: - ScrollInfo
private struct ScrollInfo {
let contentHeight: CGFloat
let viewHeight: CGFloat
let offsetY: CGFloat
let canScrollDown: Bool
let canScrollUp: Bool
init(contentHeight: CGFloat, viewHeight: CGFloat, offsetY: CGFloat) {
self.contentHeight = contentHeight
self.viewHeight = viewHeight
self.offsetY = offsetY
self.canScrollDown = viewHeight + offsetY < contentHeight
self.canScrollUp = offsetY > 0.1
}
}

View File

@@ -1,65 +0,0 @@
//
// Browser.swift
// Evergren
//
// Created by Brent Simmons on 2/23/16.
// Copyright © 2016 Ranchero Software, LLC. All rights reserved.
//
import Foundation
import RSWeb
struct Browser {
/// The user-specified default browser for opening web pages.
///
/// The user-assigned default browser, or `nil` if none was assigned
/// (i.e., the system default should be used).
static var defaultBrowser: MacWebBrowser? {
if let bundleID = AppDefaults.shared.defaultBrowserID, let browser = MacWebBrowser(bundleIdentifier: bundleID) {
return browser
}
return nil
}
/// Opens a URL in the default browser.
///
/// - Parameters:
/// - urlString: The URL to open.
/// - invert: Whether to invert the "open in background in browser" preference
static func open(_ urlString: String, invertPreference invert: Bool = false) {
// Opens according to prefs.
open(urlString, inBackground: invert ? !AppDefaults.shared.openInBrowserInBackground : AppDefaults.shared.openInBrowserInBackground)
}
/// Opens a URL in the default browser.
///
/// - Parameters:
/// - urlString: The URL to open.
/// - inBackground: Whether to open the URL in the background or not.
/// - Note: Some browsers (specifically Chromium-derived ones) will ignore the request
/// to open in the background.
static func open(_ urlString: String, inBackground: Bool) {
if let url = URL(string: urlString) {
if let defaultBrowser = defaultBrowser {
defaultBrowser.openURL(url, inBackground: inBackground)
} else {
MacWebBrowser.openURL(url, inBackground: inBackground)
}
}
}
}
extension Browser {
static var titleForOpenInBrowserInverted: String {
let openInBackgroundPref = AppDefaults.shared.openInBrowserInBackground
return openInBackgroundPref ?
NSLocalizedString("Open in Browser in Foreground", comment: "Open in Browser in Foreground menu item title") :
NSLocalizedString("Open in Browser in Background", comment: "Open in Browser in Background menu item title")
}
}

View File

@@ -1,57 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIconFile</key>
<string></string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>LSMinimumSystemVersion</key>
<string>$(MACOSX_DEPLOYMENT_TARGET)</string>
<key>NSHumanReadableCopyright</key>
<string>Copyright © 2002-2021 Ranchero Software. All rights reserved.</string>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
<key>UserAgent</key>
<string>NetNewsWire (RSS Reader; https://netnewswire.com/)</string>
<key>OrganizationIdentifier</key>
<string>$(ORGANIZATION_IDENTIFIER)</string>
<key>DeveloperEntitlements</key>
<string>$(DEVELOPER_ENTITLEMENTS)</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>Viewer</string>
<key>CFBundleURLName</key>
<string>RSS Feed</string>
<key>CFBundleURLSchemes</key>
<array>
<string>feed</string>
<string>feeds</string>
</array>
</dict>
</array>
<key>SUFeedURL</key>
<string>https://ranchero.com/downloads/netnewswire-release.xml</string>
<key>FeedURLForTestBuilds</key>
<string>https://ranchero.com/downloads/netnewswire-beta.xml</string>
</dict>
</plist>

View File

@@ -1,49 +0,0 @@
//
// MacSearchField.swift
// Multiplatform macOS
//
// Created by Stuart Breckenridge on 29/6/20.
// Copyright © 2020 Ranchero Software. All rights reserved.
//
import AppKit
import SwiftUI
final class MacSearchField: NSViewRepresentable {
typealias NSViewType = NSSearchField
func makeNSView(context: Context) -> NSSearchField {
let searchField = NSSearchField()
searchField.delegate = context.coordinator
return searchField
}
func updateNSView(_ nsView: NSSearchField, context: Context) {
}
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
class Coordinator: NSObject, NSSearchFieldDelegate {
var parent: MacSearchField
init(_ parent: MacSearchField) {
self.parent = parent
}
func searchFieldDidStartSearching(_ sender: NSSearchField) {
//
}
func searchFieldDidEndSearching(_ sender: NSSearchField) {
//
}
}
}

View File

@@ -1,28 +0,0 @@
//
// MacPreferencesView.swift
// macOS
//
// Created by Stuart Breckenridge on 27/6/20.
//
import SwiftUI
enum MacPreferencePane: Int, CaseIterable {
case general = 1
case accounts = 2
case viewing = 3
case advanced = 4
var description: String {
switch self {
case .general:
return "General"
case .accounts:
return "Accounts"
case .viewing:
return "Appearance"
case .advanced:
return "Advanced"
}
}
}

View File

@@ -1,103 +0,0 @@
//
// AccountsPreferencesModel.swift
// Multiplatform macOS
//
// Created by Stuart Breckenridge on 13/7/20.
// Copyright © 2020 Ranchero Software. All rights reserved.
//
import Foundation
import Account
import Combine
public enum AccountConfigurationSheets: Equatable {
case addAccountPicker, addSelectedAccount(AccountType), credentials, none
public static func == (lhs: AccountConfigurationSheets, rhs: AccountConfigurationSheets) -> Bool {
switch (lhs, rhs) {
case (let .addSelectedAccount(lhsType), let .addSelectedAccount(rhsType)):
return lhsType == rhsType
default:
return false
}
}
}
public class AccountsPreferencesModel: ObservableObject {
// Selected Account
public private(set) var account: Account?
// All Accounts
@Published var sortedAccounts: [Account] = []
@Published var selectedConfiguredAccountID: String? = AccountManager.shared.defaultAccount.accountID {
didSet {
if let accountID = selectedConfiguredAccountID {
account = sortedAccounts.first(where: { $0.accountID == accountID })
accountIsActive = account?.isActive ?? false
accountName = account?.name ?? ""
}
}
}
@Published var showAddAccountView: Bool = false
var selectedAccountIsDefault: Bool {
guard let selected = selectedConfiguredAccountID else {
return true
}
if selected == AccountManager.shared.defaultAccount.accountID {
return true
}
return false
}
// Edit Account
@Published var accountIsActive: Bool = false {
didSet {
account?.isActive = accountIsActive
}
}
@Published var accountName: String = "" {
didSet {
account?.name = accountName
}
}
// Sheets
@Published var showSheet: Bool = false
@Published var sheetToShow: AccountConfigurationSheets = .none {
didSet {
if sheetToShow == .none { showSheet = false } else { showSheet = true }
}
}
@Published var showDeleteConfirmation: Bool = false
// Subscriptions
var cancellables = Set<AnyCancellable>()
init() {
sortedAccounts = AccountManager.shared.sortedAccounts
NotificationCenter.default.publisher(for: .UserDidAddAccount).sink { [weak self] _ in
self?.sortedAccounts = AccountManager.shared.sortedAccounts
}.store(in: &cancellables)
NotificationCenter.default.publisher(for: .UserDidDeleteAccount).sink { [weak self] _ in
self?.selectedConfiguredAccountID = nil
self?.sortedAccounts = AccountManager.shared.sortedAccounts
self?.selectedConfiguredAccountID = AccountManager.shared.defaultAccount.accountID
}.store(in: &cancellables)
NotificationCenter.default.publisher(for: .AccountStateDidChange).sink { [weak self] notification in
guard let account = notification.object as? Account else {
return
}
if account.accountID == self?.account?.accountID {
self?.account = account
self?.accountIsActive = account.isActive
self?.accountName = account.name ?? ""
}
}.store(in: &cancellables)
}
}

View File

@@ -1,127 +0,0 @@
//
// AccountsPreferencesView.swift
// macOS
//
// Created by Stuart Breckenridge on 27/6/20.
//
import SwiftUI
import Account
struct AccountsPreferencesView: View {
@StateObject var viewModel = AccountsPreferencesModel()
@State private var hoverOnAdd: Bool = false
@State private var hoverOnRemove: Bool = false
var body: some View {
VStack {
HStack(alignment: .top, spacing: 10) {
listOfAccounts
AccountDetailView(viewModel: viewModel)
.frame(height: 300, alignment: .leading)
}
Spacer()
}
.sheet(isPresented: $viewModel.showSheet,
onDismiss: { viewModel.sheetToShow = .none },
content: {
switch viewModel.sheetToShow {
case .addAccountPicker:
AddAccountView(accountToAdd: $viewModel.sheetToShow)
case .credentials:
EditAccountCredentialsView(viewModel: viewModel)
case .none:
EmptyView()
case .addSelectedAccount(let type):
switch type {
case .onMyMac:
AddLocalAccountView()
case .feedbin:
AddFeedbinAccountView()
case .cloudKit:
AddCloudKitAccountView()
case .feedWrangler:
AddFeedWranglerAccountView()
case .newsBlur:
AddNewsBlurAccountView()
case .feedly:
AddFeedlyAccountView()
default:
AddReaderAPIAccountView(accountType: type)
}
}
})
.alert(isPresented: $viewModel.showDeleteConfirmation, content: {
Alert(title: Text("Delete \(viewModel.account!.nameForDisplay)?"),
message: Text("Are you sure you want to delete the account \"\(viewModel.account!.nameForDisplay)\"? This can not be undone."),
primaryButton: .destructive(Text("Delete"), action: {
AccountManager.shared.deleteAccount(viewModel.account!)
viewModel.showDeleteConfirmation = false
}),
secondaryButton: .cancel({
viewModel.showDeleteConfirmation = false
}))
})
}
var listOfAccounts: some View {
VStack(alignment: .leading) {
List(viewModel.sortedAccounts, id: \.accountID, selection: $viewModel.selectedConfiguredAccountID) {
ConfiguredAccountRow(account: $0)
.id($0.accountID)
}.overlay(
Group {
bottomButtonStack
}, alignment: .bottom)
}
.frame(width: 160, height: 300, alignment: .leading)
.border(Color.gray, width: 1)
}
var bottomButtonStack: some View {
VStack(alignment: .leading, spacing: 0) {
Divider()
HStack(alignment: .center, spacing: 4) {
Button(action: {
viewModel.sheetToShow = .addAccountPicker
}, label: {
Image(systemName: "plus")
.font(.title)
.frame(width: 30, height: 30)
.overlay(RoundedRectangle(cornerRadius: 4, style: .continuous)
.foregroundColor(hoverOnAdd ? Color.gray.opacity(0.1) : Color.clear))
.padding(4)
})
.buttonStyle(BorderlessButtonStyle())
.onHover { hovering in
hoverOnAdd = hovering
}
.help("Add Account")
Button(action: {
viewModel.showDeleteConfirmation = true
}, label: {
Image(systemName: "minus")
.font(.title)
.frame(width: 30, height: 30)
.overlay(RoundedRectangle(cornerRadius: 4, style: .continuous)
.foregroundColor(hoverOnRemove ? Color.gray.opacity(0.1) : Color.clear))
.padding(4)
})
.buttonStyle(BorderlessButtonStyle())
.onHover { hovering in
hoverOnRemove = hovering
}
.disabled(viewModel.selectedAccountIsDefault)
.help("Delete Account")
Spacer()
}
.background(Color.init(.windowBackgroundColor))
}
}
}

View File

@@ -1,274 +0,0 @@
//
// AddAccountView.swift
// NetNewsWire
//
// Created by Stuart Breckenridge on 28/10/20.
// Copyright © 2020 Ranchero Software. All rights reserved.
//
import SwiftUI
import Account
enum AddAccountSections: Int, CaseIterable {
case local = 0
case icloud
case web
case selfhosted
case allOrdered
var sectionHeader: String {
switch self {
case .local:
return NSLocalizedString("Local", comment: "Local Account")
case .icloud:
return NSLocalizedString("iCloud", comment: "iCloud Account")
case .web:
return NSLocalizedString("Web", comment: "Web Account")
case .selfhosted:
return NSLocalizedString("Self-hosted", comment: "Self hosted Account")
case .allOrdered:
return ""
}
}
var sectionFooter: String {
switch self {
case .local:
return NSLocalizedString("Local accounts do not sync feeds across devices.", comment: "Local Account")
case .icloud:
return NSLocalizedString("Your iCloud account syncs your feeds across your Mac and iOS devices.", comment: "iCloud Account")
case .web:
return NSLocalizedString("Web accounts sync your feeds across all your devices.", comment: "Web Account")
case .selfhosted:
return NSLocalizedString("Self-hosted accounts sync your feeds across all your devices.", comment: "Self hosted Account")
case .allOrdered:
return ""
}
}
var sectionContent: [AccountType] {
switch self {
case .local:
return [.onMyMac]
case .icloud:
return [.cloudKit]
case .web:
#if DEBUG
return [.bazQux, .feedbin, .feedly, .feedWrangler, .inoreader, .newsBlur, .theOldReader]
#else
return [.bazQux, .feedbin, .feedly, .feedWrangler, .inoreader, .newsBlur, .theOldReader]
#endif
case .selfhosted:
return [.freshRSS]
case .allOrdered:
return AddAccountSections.local.sectionContent +
AddAccountSections.icloud.sectionContent +
AddAccountSections.web.sectionContent +
AddAccountSections.selfhosted.sectionContent
}
}
}
struct AddAccountView: View {
@State private var selectedAccount: AccountType = .onMyMac
@Binding public var accountToAdd: AccountConfigurationSheets
@Environment(\.presentationMode) var presentationMode
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Choose an account type to add...")
.font(.headline)
.padding()
localAccount
icloudAccount
webAccounts
selfhostedAccounts
HStack(spacing: 12) {
Spacer()
if #available(OSX 11.0, *) {
Button(action: {
presentationMode.wrappedValue.dismiss()
}, label: {
Text("Cancel")
.frame(width: 80)
})
.help("Cancel")
.keyboardShortcut(.cancelAction)
} else {
Button(action: {
presentationMode.wrappedValue.dismiss()
}, label: {
Text("Cancel")
.frame(width: 80)
})
.accessibility(label: Text("Add Account"))
}
if #available(OSX 11.0, *) {
Button(action: {
presentationMode.wrappedValue.dismiss()
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5, execute: {
accountToAdd = AccountConfigurationSheets.addSelectedAccount(selectedAccount)
})
}, label: {
Text("Continue")
.frame(width: 80)
})
.help("Add Account")
.keyboardShortcut(.defaultAction)
} else {
Button(action: {
accountToAdd = AccountConfigurationSheets.addSelectedAccount(selectedAccount)
presentationMode.wrappedValue.dismiss()
}, label: {
Text("Continue")
.frame(width: 80)
})
}
}
.padding(.top, 12)
.padding(.bottom, 4)
}
.pickerStyle(RadioGroupPickerStyle())
.fixedSize(horizontal: false, vertical: true)
.frame(width: 420)
.padding()
}
var localAccount: some View {
VStack(alignment: .leading) {
Text("Local")
.font(.headline)
.padding(.horizontal)
Picker(selection: $selectedAccount, label: Text(""), content: {
ForEach(AddAccountSections.local.sectionContent, id: \.self, content: { account in
HStack(alignment: .center) {
account.image()
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 25, height: 25, alignment: .center)
.padding(.leading, 4)
Text(account.localizedAccountName())
}
.tag(account)
})
})
.pickerStyle(RadioGroupPickerStyle())
.offset(x: 7.5, y: 0)
Text(AddAccountSections.local.sectionFooter).foregroundColor(.gray)
.font(.caption)
.padding(.horizontal)
}
}
var icloudAccount: some View {
VStack(alignment: .leading) {
Text("iCloud")
.font(.headline)
.padding(.horizontal)
.padding(.top, 8)
Picker(selection: $selectedAccount, label: Text(""), content: {
ForEach(AddAccountSections.icloud.sectionContent, id: \.self, content: { account in
HStack(alignment: .center) {
account.image()
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 25, height: 25, alignment: .center)
.padding(.leading, 4)
Text(account.localizedAccountName())
}
.tag(account)
})
})
.offset(x: 7.5, y: 0)
.disabled(isCloudInUse())
Text(AddAccountSections.icloud.sectionFooter).foregroundColor(.gray)
.font(.caption)
.padding(.horizontal)
}
}
var webAccounts: some View {
VStack(alignment: .leading) {
Text("Web")
.font(.headline)
.padding(.horizontal)
.padding(.top, 8)
Picker(selection: $selectedAccount, label: Text(""), content: {
ForEach(AddAccountSections.web.sectionContent, id: \.self, content: { account in
HStack(alignment: .center) {
account.image()
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 25, height: 25, alignment: .center)
.padding(.leading, 4)
Text(account.localizedAccountName())
}
.tag(account)
})
})
.offset(x: 7.5, y: 0)
Text(AddAccountSections.web.sectionFooter).foregroundColor(.gray)
.font(.caption)
.padding(.horizontal)
}
}
var selfhostedAccounts: some View {
VStack(alignment: .leading) {
Text("Self-hosted")
.font(.headline)
.padding(.horizontal)
.padding(.top, 8)
Picker(selection: $selectedAccount, label: Text(""), content: {
ForEach(AddAccountSections.selfhosted.sectionContent, id: \.self, content: { account in
HStack(alignment: .center) {
account.image()
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 25, height: 25, alignment: .center)
.padding(.leading, 4)
Text(account.localizedAccountName())
}.tag(account)
})
})
.offset(x: 7.5, y: 0)
Text(AddAccountSections.selfhosted.sectionFooter).foregroundColor(.gray)
.font(.caption)
.padding(.horizontal)
}
}
private func isCloudInUse() -> Bool {
AccountManager.shared.accounts.contains(where: { $0.type == .cloudKit })
}
private func isRestricted(_ accountType: AccountType) -> Bool {
if AppDefaults.shared.isDeveloperBuild && (accountType == .feedly || accountType == .feedWrangler || accountType == .inoreader) {
return true
}
return false
}
}

View File

@@ -1,29 +0,0 @@
//
// ConfiguredAccountRow.swift
// Multiplatform macOS
//
// Created by Stuart Breckenridge on 13/7/20.
// Copyright © 2020 Ranchero Software. All rights reserved.
//
import SwiftUI
import Account
struct ConfiguredAccountRow: View {
var account: Account
var body: some View {
HStack(alignment: .center) {
if let img = account.smallIcon?.image {
Image(rsImage: img)
.resizable()
.frame(width: 20, height: 20)
.aspectRatio(contentMode: .fit)
}
Text(account.nameForDisplay)
}.padding(.vertical, 4)
}
}

View File

@@ -1,83 +0,0 @@
//
// AccountDetailView.swift
// Multiplatform macOS
//
// Created by Stuart Breckenridge on 14/7/20.
// Copyright © 2020 Ranchero Software. All rights reserved.
//
import SwiftUI
import Account
import Combine
struct AccountDetailView: View {
@ObservedObject var viewModel: AccountsPreferencesModel
var body: some View {
ZStack {
RoundedRectangle(cornerRadius: 8, style: .circular)
.foregroundColor(Color.secondary.opacity(0.1))
.padding(.top, 8)
VStack {
editAccountHeader
if viewModel.account != nil {
editAccountForm
}
Spacer()
}
}
}
var editAccountHeader: some View {
HStack {
Spacer()
Button("Account Information", action: {})
Spacer()
}
.padding([.leading, .trailing, .bottom], 4)
}
var editAccountForm: some View {
Form(content: {
HStack(alignment: .top) {
Text("Type: ")
.frame(width: 50)
VStack(alignment: .leading) {
Text(viewModel.account!.defaultName)
Toggle("Active", isOn: $viewModel.accountIsActive)
}
}
HStack(alignment: .top) {
Text("Name: ")
.frame(width: 50)
VStack(alignment: .leading) {
TextField(viewModel.account!.name ?? "", text: $viewModel.accountName)
.textFieldStyle(RoundedBorderTextFieldStyle())
Text("The name appears in the sidebar. It can be anything you want. You can even use emoji. 🎸")
.foregroundColor(.secondary)
}
}
Spacer()
if viewModel.account?.type != .onMyMac {
HStack {
Spacer()
Button("Credentials", action: {
viewModel.sheetToShow = .credentials
})
Spacer()
}
}
})
.padding()
}
}
struct AccountDetailView_Previews: PreviewProvider {
static var previews: some View {
AccountDetailView(viewModel: AccountsPreferencesModel())
}
}

View File

@@ -1,284 +0,0 @@
//
// EditAccountCredentialsModel.swift
// Multiplatform macOS
//
// Created by Stuart Breckenridge on 14/7/20.
// Copyright © 2020 Ranchero Software. All rights reserved.
//
import Foundation
import Account
import Secrets
import RSCore
class EditAccountCredentialsModel: ObservableObject {
@Published var userName: String = ""
@Published var password: String = ""
@Published var apiUrl: String = ""
@Published var accountIsUpdatingCredentials: Bool = false
@Published var accountCredentialsWereUpdated: Bool = false
@Published var error: AccountUpdateErrors = .none {
didSet {
if error == .none {
showError = false
} else {
showError = true
}
}
}
@Published var showError: Bool = false
func updateAccountCredentials(_ account: Account) {
switch account.type {
case .onMyMac:
return
case .feedbin:
updateFeedbin(account)
case .cloudKit:
return
case .feedWrangler:
updateFeedWrangler(account)
case .feedly:
updateFeedly(account)
case .freshRSS:
updateReaderAccount(account)
case .newsBlur:
updateNewsblur(account)
case .inoreader:
updateReaderAccount(account)
case .bazQux:
updateReaderAccount(account)
case .theOldReader:
updateReaderAccount(account)
}
}
func retrieveCredentials(_ account: Account) {
switch account.type {
case .feedbin:
let credentials = try? account.retrieveCredentials(type: .basic)
userName = credentials?.username ?? ""
case .feedWrangler:
let credentials = try? account.retrieveCredentials(type: .feedWranglerBasic)
userName = credentials?.username ?? ""
case .feedly:
return
case .freshRSS:
let credentials = try? account.retrieveCredentials(type: .readerBasic)
userName = credentials?.username ?? ""
case .newsBlur:
let credentials = try? account.retrieveCredentials(type: .newsBlurBasic)
userName = credentials?.username ?? ""
default:
return
}
}
}
// MARK:- Update API
extension EditAccountCredentialsModel {
func updateFeedbin(_ account: Account) {
accountIsUpdatingCredentials = true
let credentials = Credentials(type: .basic, username: userName, secret: password)
Account.validateCredentials(type: .feedbin, credentials: credentials) { [weak self] result in
guard let self = self else { return }
self.accountIsUpdatingCredentials = false
switch result {
case .success(let validatedCredentials):
guard let validatedCredentials = validatedCredentials else {
self.error = .invalidUsernamePassword
return
}
do {
try account.removeCredentials(type: .basic)
try account.storeCredentials(validatedCredentials)
self.accountCredentialsWereUpdated = true
account.refreshAll(completion: { result in
switch result {
case .success:
break
case .failure(let error):
self.error = .other(error: error)
}
})
} catch {
self.error = .keyChainError
}
case .failure:
self.error = .networkError
}
}
}
func updateFeedWrangler(_ account: Account) {
accountIsUpdatingCredentials = true
let credentials = Credentials(type: .feedWranglerBasic, username: userName, secret: password)
Account.validateCredentials(type: .feedWrangler, credentials: credentials) { [weak self] result in
guard let self = self else { return }
self.accountIsUpdatingCredentials = false
switch result {
case .success(let validatedCredentials):
guard let validatedCredentials = validatedCredentials else {
self.error = .invalidUsernamePassword
return
}
do {
try account.removeCredentials(type: .feedWranglerBasic)
try account.removeCredentials(type: .feedWranglerToken)
try account.storeCredentials(credentials)
try account.storeCredentials(validatedCredentials)
self.accountCredentialsWereUpdated = true
account.refreshAll(completion: { result in
switch result {
case .success:
break
case .failure(let error):
self.error = .other(error: error)
}
})
} catch {
self.error = .keyChainError
}
case .failure:
self.error = .networkError
}
}
}
func updateFeedly(_ account: Account) {
accountIsUpdatingCredentials = true
let updateAccount = OAuthAccountAuthorizationOperation(accountType: .feedly)
updateAccount.delegate = self
#if os(macOS)
updateAccount.presentationAnchor = NSApplication.shared.windows.last
#endif
MainThreadOperationQueue.shared.add(updateAccount)
}
func updateReaderAccount(_ account: Account) {
accountIsUpdatingCredentials = true
let credentials = Credentials(type: .readerBasic, username: userName, secret: password)
Account.validateCredentials(type: account.type, credentials: credentials) { [weak self] result in
guard let self = self else { return }
self.accountIsUpdatingCredentials = false
switch result {
case .success(let validatedCredentials):
guard let validatedCredentials = validatedCredentials else {
self.error = .invalidUsernamePassword
return
}
do {
try account.removeCredentials(type: .readerBasic)
try account.removeCredentials(type: .readerAPIKey)
try account.storeCredentials(credentials)
try account.storeCredentials(validatedCredentials)
self.accountCredentialsWereUpdated = true
account.refreshAll(completion: { result in
switch result {
case .success:
break
case .failure(let error):
self.error = .other(error: error)
}
})
} catch {
self.error = .keyChainError
}
case .failure:
self.error = .networkError
}
}
}
func updateNewsblur(_ account: Account) {
accountIsUpdatingCredentials = true
let credentials = Credentials(type: .newsBlurBasic, username: userName, secret: password)
Account.validateCredentials(type: .newsBlur, credentials: credentials) { [weak self] result in
guard let self = self else { return }
self.accountIsUpdatingCredentials = false
switch result {
case .success(let validatedCredentials):
guard let validatedCredentials = validatedCredentials else {
self.error = .invalidUsernamePassword
return
}
do {
try account.removeCredentials(type: .newsBlurBasic)
try account.removeCredentials(type: .newsBlurSessionId)
try account.storeCredentials(credentials)
try account.storeCredentials(validatedCredentials)
self.accountCredentialsWereUpdated = true
account.refreshAll(completion: { result in
switch result {
case .success:
break
case .failure(let error):
self.error = .other(error: error)
}
})
} catch {
self.error = .keyChainError
}
case .failure:
self.error = .networkError
}
}
}
}
// MARK:- OAuthAccountAuthorizationOperationDelegate
extension EditAccountCredentialsModel: OAuthAccountAuthorizationOperationDelegate {
func oauthAccountAuthorizationOperation(_ operation: OAuthAccountAuthorizationOperation, didCreate account: Account) {
accountIsUpdatingCredentials = false
accountCredentialsWereUpdated = true
account.refreshAll { [weak self] result in
switch result {
case .success:
break
case .failure(let error):
self?.error = .other(error: error)
}
}
}
func oauthAccountAuthorizationOperation(_ operation: OAuthAccountAuthorizationOperation, didFailWith error: Error) {
accountIsUpdatingCredentials = false
self.error = .other(error: error)
}
}

View File

@@ -1,94 +0,0 @@
//
// EditAccountCredentialsView.swift
// Multiplatform macOS
//
// Created by Stuart Breckenridge on 14/7/20.
// Copyright © 2020 Ranchero Software. All rights reserved.
//
import SwiftUI
import Secrets
struct EditAccountCredentialsView: View {
@Environment(\.presentationMode) var presentationMode
@StateObject private var editModel = EditAccountCredentialsModel()
@ObservedObject var viewModel: AccountsPreferencesModel
var body: some View {
Form {
HStack {
Spacer()
Image(rsImage: viewModel.account!.smallIcon!.image)
.resizable()
.frame(width: 30, height: 30)
Text(viewModel.account?.nameForDisplay ?? "")
Spacer()
}.padding()
HStack(alignment: .center) {
VStack(alignment: .trailing, spacing: 12) {
Text("Username: ")
Text("Password: ")
if viewModel.account?.type == .freshRSS {
Text("API URL: ")
}
}.frame(width: 75)
VStack(alignment: .leading, spacing: 12) {
TextField("Username", text: $editModel.userName)
SecureField("Password", text: $editModel.password)
if viewModel.account?.type == .freshRSS {
TextField("API URL", text: $editModel.apiUrl)
}
}
}.textFieldStyle(RoundedBorderTextFieldStyle())
Spacer()
HStack{
if editModel.accountIsUpdatingCredentials {
ProgressView("Updating")
}
Spacer()
Button("Cancel", action: {
presentationMode.wrappedValue.dismiss()
})
if viewModel.account?.type != .freshRSS {
Button("Update", action: {
editModel.updateAccountCredentials(viewModel.account!)
}).disabled(editModel.userName.count == 0 || editModel.password.count == 0)
} else {
Button("Update", action: {
editModel.updateAccountCredentials(viewModel.account!)
}).disabled(editModel.userName.count == 0 || editModel.password.count == 0 || editModel.apiUrl.count == 0)
}
}
}.onAppear {
editModel.retrieveCredentials(viewModel.account!)
}
.onChange(of: editModel.accountCredentialsWereUpdated) { value in
if value == true {
viewModel.sheetToShow = .none
presentationMode.wrappedValue.dismiss()
}
}
.alert(isPresented: $editModel.showError) {
Alert(title: Text("Error Adding Account"),
message: Text(editModel.error.description),
dismissButton: .default(Text("Dismiss"),
action: {
editModel.error = .none
}))
}
.frame(idealWidth: 300, idealHeight: 200, alignment: .top)
.padding()
}
}
struct EditAccountCredentials_Previews: PreviewProvider {
static var previews: some View {
EditAccountCredentialsView(viewModel: AccountsPreferencesModel())
}
}

View File

@@ -1,39 +0,0 @@
//
// AccountUpdateErrors.swift
// Multiplatform macOS
//
// Created by Stuart Breckenridge on 14/7/20.
// Copyright © 2020 Ranchero Software. All rights reserved.
//
import Foundation
enum AccountUpdateErrors: CustomStringConvertible {
case invalidUsernamePassword, invalidUsernamePasswordAPI, networkError, keyChainError, other(error: Error) , none
var description: String {
switch self {
case .invalidUsernamePassword:
return NSLocalizedString("Invalid email or password combination.", comment: "Invalid email/password combination.")
case .invalidUsernamePasswordAPI:
return NSLocalizedString("Invalid email, password, or API URL combination.", comment: "Invalid email/password/API combination.")
case .networkError:
return NSLocalizedString("Network Error. Please try later.", comment: "Network Error. Please try later.")
case .keyChainError:
return NSLocalizedString("Keychain error while storing credentials.", comment: "Credentials Error")
case .other(let error):
return NSLocalizedString(error.localizedDescription, comment: "Other add account error")
default:
return NSLocalizedString("N/A", comment: "N/A")
}
}
static func ==(lhs: AccountUpdateErrors, rhs: AccountUpdateErrors) -> Bool {
switch (lhs, rhs) {
case (.other(let lhsError), .other(let rhsError)):
return lhsError.localizedDescription == rhsError.localizedDescription
default:
return false
}
}
}

View File

@@ -1,32 +0,0 @@
//
// AdvancedPreferencesModel.swift
// Multiplatform macOS
//
// Created by Stuart Breckenridge on 16/7/20.
// Copyright © 2020 Ranchero Software. All rights reserved.
//
import Foundation
class AdvancedPreferencesModel: ObservableObject {
let releaseBuildsURL = Bundle.main.infoDictionary!["SUFeedURL"]! as! String
let testBuildsURL = Bundle.main.infoDictionary!["FeedURLForTestBuilds"]! as! String
let appcastDefaultsKey = "SUFeedURL"
init() {
if AppDefaults.shared.downloadTestBuilds == false {
AppDefaults.store.setValue(releaseBuildsURL, forKey: appcastDefaultsKey)
} else {
AppDefaults.store.setValue(testBuildsURL, forKey: appcastDefaultsKey)
}
}
func updateAppcast() {
if AppDefaults.shared.downloadTestBuilds == false {
AppDefaults.store.setValue(releaseBuildsURL, forKey: appcastDefaultsKey)
} else {
AppDefaults.store.setValue(testBuildsURL, forKey: appcastDefaultsKey)
}
}
}

View File

@@ -1,46 +0,0 @@
//
// AdvancedPreferencesView.swift
// macOS
//
// Created by Stuart Breckenridge on 27/6/20.
//
import SwiftUI
struct AdvancedPreferencesView: View {
@StateObject private var preferences = AppDefaults.shared
@StateObject private var viewModel = AdvancedPreferencesModel()
var body: some View {
Form {
Toggle("Check for app updates automatically", isOn: $preferences.checkForUpdatesAutomatically)
Toggle("Download Test Builds", isOn: $preferences.downloadTestBuilds)
Text("If youre not sure, dont enable test builds. Test builds may have bugs, which may include crashing bugs and data loss.")
.foregroundColor(.secondary)
HStack {
Spacer()
Button("Check for Updates") {
appDelegate.softwareUpdater.checkForUpdates()
}
Spacer()
}
Toggle("Send Crash Logs Automatically", isOn: $preferences.sendCrashLogs)
Divider()
HStack {
Spacer()
Button("Privacy Policy", action: {
NSWorkspace.shared.open(URL(string: "https://netnewswire.com/privacypolicy")!)
})
Spacer()
}
}
.onChange(of: preferences.downloadTestBuilds, perform: { _ in
viewModel.updateAppcast()
})
.frame(width: 400, alignment: .center)
.lineLimit(3)
}
}

View File

@@ -1,129 +0,0 @@
//
// GeneralPreferencesModel.swift
// Multiplatform macOS
//
// Created by Stuart Breckenridge on 12/7/20.
// Copyright © 2020 Ranchero Software. All rights reserved.
//
import Foundation
class GeneralPreferencesModel: ObservableObject {
@Published var rssReaders = [RSSReader]()
@Published var readerSelection: Int = 0 {
willSet {
if newValue != readerSelection {
registerAppWithBundleID(rssReaders[newValue].bundleID)
}
}
}
private let readerInfo = RSSReaderInfo()
init() {
prepareRSSReaders()
}
}
// MARK:- RSS Readers
private extension GeneralPreferencesModel {
func prepareRSSReaders() {
// Populate rssReaders
var thisApp = RSSReader(bundleID: Bundle.main.bundleIdentifier!)
thisApp?.nameMinusAppSuffix.append(" (this app—multiplatform)")
let otherRSSReaders = readerInfo.rssReaders.filter { $0.bundleID != Bundle.main.bundleIdentifier! }.sorted(by: { $0.nameMinusAppSuffix < $1.nameMinusAppSuffix })
rssReaders.append(thisApp!)
rssReaders.append(contentsOf: otherRSSReaders)
if readerInfo.defaultRSSReaderBundleID != nil {
let defaultReader = rssReaders.filter({ $0.bundleID == readerInfo.defaultRSSReaderBundleID })
if defaultReader.count == 1 {
let reader = defaultReader[0]
readerSelection = rssReaders.firstIndex(of: reader)!
}
}
}
func registerAppWithBundleID(_ bundleID: String) {
NSWorkspace.shared.setDefaultAppBundleID(forURLScheme: "feed", to: bundleID)
NSWorkspace.shared.setDefaultAppBundleID(forURLScheme: "feeds", to: bundleID)
}
}
// MARK: - RSSReaderInfo
struct RSSReaderInfo {
var defaultRSSReaderBundleID: String? {
NSWorkspace.shared.defaultAppBundleID(forURLScheme: RSSReaderInfo.feedURLScheme)
}
let rssReaders: Set<RSSReader>
static let feedURLScheme = "feed:"
init() {
self.rssReaders = RSSReaderInfo.fetchRSSReaders()
}
static func fetchRSSReaders() -> Set<RSSReader> {
let rssReaderBundleIDs = NSWorkspace.shared.bundleIDsForApps(forURLScheme: feedURLScheme)
var rssReaders = Set<RSSReader>()
rssReaderBundleIDs.forEach { (bundleID) in
if let reader = RSSReader(bundleID: bundleID) {
rssReaders.insert(reader)
}
}
return rssReaders
}
}
// MARK: - RSSReader
struct RSSReader: Hashable {
let bundleID: String
let name: String
var nameMinusAppSuffix: String
let path: String
init?(bundleID: String) {
guard let path = NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleID) else {
return nil
}
self.path = path.path
self.bundleID = bundleID
let name = (self.path as NSString).lastPathComponent
self.name = name
if name.hasSuffix(".app") {
self.nameMinusAppSuffix = name.stripping(suffix: ".app")
}
else {
self.nameMinusAppSuffix = name
}
}
// MARK: - Hashable
func hash(into hasher: inout Hasher) {
hasher.combine(bundleID)
}
// MARK: - Equatable
static func ==(lhs: RSSReader, rhs: RSSReader) -> Bool {
return lhs.bundleID == rhs.bundleID
}
}

View File

@@ -1,54 +0,0 @@
//
// GeneralPreferencesView.swift
// macOS
//
// Created by Stuart Breckenridge on 27/6/20.
//
import SwiftUI
struct GeneralPreferencesView: View {
@StateObject private var defaults = AppDefaults.shared
@StateObject private var preferences = GeneralPreferencesModel()
var body: some View {
Form {
Picker("Refresh feeds:",
selection: $defaults.interval,
content: {
ForEach(RefreshInterval.allCases, content: { interval in
Text(interval.description())
.tag(interval.rawValue)
})
})
Picker("Default RSS reader:", selection: $preferences.readerSelection, content: {
ForEach(0..<preferences.rssReaders.count, content: { index in
if index > 0 && preferences.rssReaders[index].nameMinusAppSuffix.contains("NetNewsWire") {
Text(preferences.rssReaders[index].nameMinusAppSuffix.appending(" (old version)"))
} else {
Text(preferences.rssReaders[index].nameMinusAppSuffix)
.tag(index)
}
})
})
Toggle("Confirm when deleting feeds and folders", isOn: $defaults.sidebarConfirmDelete)
Toggle("Open webpages in background in browser", isOn: $defaults.openInBrowserInBackground)
Toggle("Hide Unread Count in Dock", isOn: $defaults.hideDockUnreadCount)
Picker("Safari Extension:",
selection: $defaults.subscribeToFeedsInDefaultBrowser,
content: {
Text("Open feeds in NetNewsWire").tag(false)
Text("Open feeds in default news reader").tag(true)
}).pickerStyle(RadioGroupPickerStyle())
}
.frame(width: 400, alignment: .center)
.lineLimit(2)
}
}

View File

@@ -1,99 +0,0 @@
//
// LayoutPreferencesView.swift
// Multiplatform macOS
//
// Created by Stuart Breckenridge on 17/7/20.
// Copyright © 2020 Ranchero Software. All rights reserved.
//
import SwiftUI
struct LayoutPreferencesView: View {
@StateObject private var defaults = AppDefaults.shared
private let colorPalettes = UserInterfaceColorPalette.allCases
private let sampleTitle = "Lorem dolor sed viverra ipsum. Gravida rutrum quisque non tellus. Rutrum tellus pellentesque eu tincidunt tortor. Sed blandit libero volutpat sed cras ornare. Et netus et malesuada fames ac. Ultrices eros in cursus turpis massa tincidunt dui ut ornare. Lacus sed viverra tellus in. Sollicitudin ac orci phasellus egestas. Purus in mollis nunc sed. Sollicitudin ac orci phasellus egestas tellus rutrum tellus pellentesque. Interdum consectetur libero id faucibus nisl tincidunt eget."
var body: some View {
VStack {
Form {
Picker("Appearance", selection: $defaults.userInterfaceColorPalette, content: {
ForEach(colorPalettes, id: \.self, content: {
Text($0.description)
})
})
Divider()
Text("Timeline: ")
Picker("Number of Lines", selection: $defaults.timelineNumberOfLines, content: {
ForEach(1..<6, content: { i in
Text(String(i))
.tag(Double(i))
})
}).padding(.leading, 16)
Slider(value: $defaults.timelineIconDimensions, in: 20...60, step: 10, minimumValueLabel: Text("Small"), maximumValueLabel: Text("Large"), label: {
Text("Icon size")
}).padding(.leading, 16)
}
timelineRowPreview
.frame(width: 300)
.padding()
.overlay(
RoundedRectangle(cornerRadius: 8, style: .continuous)
.stroke(Color.gray, lineWidth: 1)
)
.animation(.default)
Text("PREVIEW")
.font(.caption)
.tracking(0.3)
Spacer()
}.frame(width: 400, height: 300, alignment: .center)
}
var timelineRowPreview: some View {
HStack(alignment: .top) {
Image(systemName: "circle.fill")
.resizable()
.frame(width: 10, height: 10, alignment: .top)
.foregroundColor(.accentColor)
Image(systemName: "paperplane.circle")
.resizable()
.frame(width: CGFloat(defaults.timelineIconDimensions), height: CGFloat(defaults.timelineIconDimensions), alignment: .top)
.foregroundColor(.accentColor)
VStack(alignment: .leading, spacing: 4) {
Text(sampleTitle)
.font(.headline)
.lineLimit(Int(defaults.timelineNumberOfLines))
HStack {
Text("Feed Name")
.foregroundColor(.secondary)
.font(.footnote)
Spacer()
Text("10:31")
.font(.footnote)
.foregroundColor(.secondary)
}
}
}
}
}
struct SwiftUIView_Previews: PreviewProvider {
static var previews: some View {
LayoutPreferencesView()
}
}

View File

@@ -1,18 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.app-sandbox</key>
<false/>
<key>com.apple.security.automation.apple-events</key>
<true/>
<key>com.apple.security.files.user-selected.read-write</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>
<key>com.apple.security.temporary-exception.apple-events</key>
<array>
<string>com.red-sweater.marsedit4</string>
</array>
</dict>
</plist>

View File

@@ -1,28 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.developer.aps-environment</key>
<string>development</string>
<key>com.apple.developer.icloud-container-identifiers</key>
<array>
<string>iCloud.$(ORGANIZATION_IDENTIFIER).NetNewsWire</string>
</array>
<key>com.apple.developer.icloud-services</key>
<array>
<string>CloudKit</string>
</array>
<key>com.apple.security.app-sandbox</key>
<false/>
<key>com.apple.security.automation.apple-events</key>
<true/>
<key>com.apple.security.files.user-selected.read-write</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>
<key>com.apple.security.temporary-exception.apple-events</key>
<array>
<string>com.red-sweater.marsedit4</string>
</array>
</dict>
</plist>