Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions wire-ios/Wire-iOS.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -2442,8 +2442,9 @@
"@executable_path/Frameworks",
);
LIBRARY_SEARCH_PATHS = "$(inherited)";
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SUPPORTS_MACCATALYST = NO;
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: Will this change apply to the production apps too?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In my opinion we shouldn't do that. Users already have access to a mac app. At least this seems like a product decision.

SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
SWIFT_COMPILATION_MODE = singlefile;
TARGETED_DEVICE_FAMILY = "1,2";
Expand All @@ -2459,8 +2460,9 @@
"@executable_path/Frameworks",
);
LIBRARY_SEARCH_PATHS = "$(inherited)";
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SUPPORTS_MACCATALYST = NO;
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES;
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
TARGETED_DEVICE_FAMILY = "1,2";
};
Expand All @@ -2470,6 +2472,7 @@
isa = XCBuildConfiguration;
baseConfigurationReference = 01B71A962D6D2DAB002C5A38 /* Tests-Debug.xcconfig */;
buildSettings = {
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SUPPORTS_MACCATALYST = NO;
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
Expand All @@ -2482,6 +2485,7 @@
isa = XCBuildConfiguration;
baseConfigurationReference = 01B71A962D6D2DAB002C5A38 /* Tests-Debug.xcconfig */;
buildSettings = {
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SUPPORTS_MACCATALYST = NO;
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
enableGPUValidationMode = "1"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ extension UIApplication: ApplicationProtocol {
}

static func wr_requestOrWarnAboutPhotoLibraryAccess(_ grantedHandler: @escaping (Bool) -> Void) {
PHPhotoLibrary.requestAuthorization { status in
PHPhotoLibrary.requestAuthorization(for: .readWrite) { status in
DispatchQueue.main.async {
switch status {
case .restricted:
Expand All @@ -68,7 +68,7 @@ extension UIApplication: ApplicationProtocol {
case .authorized:
grantedHandler(true)
case .limited:
fallthrough
grantedHandler(true)
Comment thread
samwyndham marked this conversation as resolved.
@unknown default:
break
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ extension SessionManager {
let isCallKitEnabled = !isCallKitDisabled
let hasAudioPermissions = AVCaptureDevice.authorizationStatus(for: AVMediaType.audio) == AVAuthorizationStatus
.authorized
let isCallKitSupported = !UIDevice.isSimulator
let isCallKitSupported = !UIDevice.isSimulator && !ProcessInfo.processInfo.isiOSAppOnMac

if isCallKitEnabled, isCallKitSupported, hasAudioPermissions {
callNotificationStyle = .callKit
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,21 +85,35 @@

// SETUP INPUTS

let availableInputs = [AVCaptureDevice.Position.front, .back]
var availableInputs = [AVCaptureDevice.Position.front, .back]
.compactMap { cameraDevice(for: $0) }
.compactMap { try? AVCaptureDeviceInput(device: $0) }
.filter { session.canAddInput($0) }

// On Mac, builtInWideAngleCamera at front/back positions is unavailable.
// Fall back to the system default video device (e.g. FaceTime HD camera).
if availableInputs.isEmpty,
let defaultDevice = AVCaptureDevice.default(for: .video),
let input = try? AVCaptureDeviceInput(device: defaultDevice),
session.canAddInput(input) {
availableInputs = [input]
}

switch availableInputs.count {
case 1:
let input = availableInputs.first!

if input.device.position == .front {
switch input.device.position {
case .front:
currentCamera = .front
frontCameraDeviceInput = input
} else {
case .back:
currentCamera = .back
backCameraDeviceInput = input
default:
// Mac cameras report .unspecified; treat as front (facing the user).
currentCamera = .front
frontCameraDeviceInput = input

Check warning on line 116 in wire-ios/Wire-iOS/Sources/UserInterface/Camera/CameraController.swift

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Either merge this branch with the identical one on line 107 or change one of the implementations.

See more on https://sonarcloud.io/project/issues?id=wireapp_wire-ios&issues=AZ2_6krz8nV5pkv_ksOW&open=AZ2_6krz8nV5pkv_ksOW&pullRequest=4636
}

case 2:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
//
// Wire
// Copyright (C) 2026 Wire Swiss GmbH
//
// This program 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.
//
// This program 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 this program. If not, see http://www.gnu.org/licenses/.
//

import AVFoundation
import UIKit

/// A minimal video recorder for Mac (Designed for iPad).
/// UIImagePickerController with .camera source crashes on Mac because Portrait Effects
/// tries to create Metal textures with unsupported IOSurface formats. This replaces it.
final class MacVideoRecorderViewController: UIViewController {
Comment thread
samwyndham marked this conversation as resolved.

var maxDuration: TimeInterval = 240
var onVideoRecorded: ((URL) -> Void)?

private let session = AVCaptureSession()
private let sessionQueue = DispatchQueue(label: "com.wire.mac-video-recorder.session")
private let movieOutput = AVCaptureMovieFileOutput()
private var previewLayer: AVCaptureVideoPreviewLayer!
private var recordingTimer: Timer?
private var elapsedSeconds = 0

private lazy var recordButton: UIButton = {
var config = UIButton.Configuration.filled()
config.cornerStyle = .capsule
config.baseForegroundColor = .white
config.baseBackgroundColor = .systemRed
config.contentInsets = NSDirectionalEdgeInsets(top: 14, leading: 28, bottom: 14, trailing: 28)
let button = UIButton(configuration: config)
button.setTitle(L10n.Localizable.Content.File.takeVideo, for: .normal)
button.setTitle("Stop", for: .selected)
button.addTarget(self, action: #selector(toggleRecording), for: .touchUpInside)
button.translatesAutoresizingMaskIntoConstraints = false
return button
}()

private lazy var cancelButton: UIButton = {
var config = UIButton.Configuration.plain()
config.baseForegroundColor = .white
let button = UIButton(configuration: config)
button.setTitle(L10n.Localizable.General.cancel, for: .normal)
button.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside)
button.translatesAutoresizingMaskIntoConstraints = false
return button
}()

private lazy var timerLabel: UILabel = {
let label = UILabel()
label.textColor = .white
label.font = .monospacedDigitSystemFont(ofSize: 17, weight: .semibold)
label.text = formatTime(0)
label.translatesAutoresizingMaskIntoConstraints = false
label.isHidden = true
return label
}()

private lazy var recordingIndicator: UIView = {
let view = UIView()
view.backgroundColor = .systemRed
view.layer.cornerRadius = 5
view.translatesAutoresizingMaskIntoConstraints = false
view.isHidden = true
NSLayoutConstraint.activate([
view.widthAnchor.constraint(equalToConstant: 10),
view.heightAnchor.constraint(equalToConstant: 10)
])
return view
}()

override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .black
setupSession()
setupUI()
}

override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
sessionQueue.async { self.session.startRunning() }
}

override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
if movieOutput.isRecording { movieOutput.stopRecording() }
sessionQueue.async { self.session.stopRunning() }
recordingTimer?.invalidate()
}

override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
previewLayer.frame = view.bounds
}

private func setupSession() {
session.beginConfiguration()
// .medium avoids high-quality video configurations that tend to trigger Portrait Effects
session.sessionPreset = .medium
session.automaticallyConfiguresCaptureDeviceForWideColor = false

if let device = AVCaptureDevice.default(for: .video),
let input = try? AVCaptureDeviceInput(device: device),
session.canAddInput(input) {
session.addInput(input)
}

if let device = AVCaptureDevice.default(for: .audio),
let input = try? AVCaptureDeviceInput(device: device),
session.canAddInput(input) {
session.addInput(input)
}

Check failure on line 125 in wire-ios/Wire-iOS/Sources/UserInterface/Camera/MacVideoRecorderViewController.swift

View workflow job for this annotation

GitHub Actions / SwiftLint

Lines should not have trailing whitespace (trailing_whitespace)

Check warning on line 125 in wire-ios/Wire-iOS/Sources/UserInterface/Camera/MacVideoRecorderViewController.swift

View workflow job for this annotation

GitHub Actions / SwiftFormat

Remove trailing space at end of a line. (trailingSpace)
if session.canAddOutput(movieOutput) {
session.addOutput(movieOutput)
}

session.commitConfiguration()

previewLayer = AVCaptureVideoPreviewLayer(session: session)
previewLayer.videoGravity = .resizeAspectFill
view.layer.insertSublayer(previewLayer, at: 0)
}

private func setupUI() {
let timerStack = UIStackView(arrangedSubviews: [recordingIndicator, timerLabel])
timerStack.axis = .horizontal
timerStack.spacing = 6
timerStack.alignment = .center
timerStack.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(timerStack)

view.addSubview(cancelButton)
view.addSubview(recordButton)

NSLayoutConstraint.activate([
timerStack.centerXAnchor.constraint(equalTo: view.centerXAnchor),
timerStack.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 20),

recordButton.centerXAnchor.constraint(equalTo: view.centerXAnchor),
recordButton.bottomAnchor.constraint(
equalTo: view.safeAreaLayoutGuide.bottomAnchor,
constant: -28
),

cancelButton.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20),
cancelButton.centerYAnchor.constraint(equalTo: recordButton.centerYAnchor)
])
}

@objc private func toggleRecording() {

Check warning on line 163 in wire-ios/Wire-iOS/Sources/UserInterface/Camera/MacVideoRecorderViewController.swift

View workflow job for this annotation

GitHub Actions / SwiftFormat

Wrap @attributes onto a separate line, or keep them on the same line. (wrapAttributes)
if movieOutput.isRecording {
movieOutput.stopRecording()
recordButton.isSelected = false
recordingTimer?.invalidate()
} else {
let url = URL(fileURLWithPath: NSTemporaryDirectory())
.appendingPathComponent(UUID().uuidString)
.appendingPathExtension("mov")
movieOutput.maxRecordedDuration = CMTime(seconds: maxDuration, preferredTimescale: 600)
movieOutput.startRecording(to: url, recordingDelegate: self)
recordButton.isSelected = true
elapsedSeconds = 0
timerLabel.isHidden = false
recordingIndicator.isHidden = false
timerLabel.text = formatTime(0)
recordingTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
guard let self else { return }
self.elapsedSeconds += 1

Check warning on line 181 in wire-ios/Wire-iOS/Sources/UserInterface/Camera/MacVideoRecorderViewController.swift

View workflow job for this annotation

GitHub Actions / SwiftFormat

Insert/remove explicit self where applicable. (redundantSelf)
self.timerLabel.text = self.formatTime(self.elapsedSeconds)

Check warning on line 182 in wire-ios/Wire-iOS/Sources/UserInterface/Camera/MacVideoRecorderViewController.swift

View workflow job for this annotation

GitHub Actions / SwiftFormat

Insert/remove explicit self where applicable. (redundantSelf)
}
}
}

@objc private func cancelTapped() {

Check warning on line 187 in wire-ios/Wire-iOS/Sources/UserInterface/Camera/MacVideoRecorderViewController.swift

View workflow job for this annotation

GitHub Actions / SwiftFormat

Wrap @attributes onto a separate line, or keep them on the same line. (wrapAttributes)
if movieOutput.isRecording { movieOutput.stopRecording() }
dismiss(animated: true)
}

private func formatTime(_ seconds: Int) -> String {
String(format: "%d:%02d", seconds / 60, seconds % 60)
}
}

extension MacVideoRecorderViewController: AVCaptureFileOutputRecordingDelegate {

func fileOutput(
_ output: AVCaptureFileOutput,

Check warning on line 200 in wire-ios/Wire-iOS/Sources/UserInterface/Camera/MacVideoRecorderViewController.swift

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove the unused function parameter "output" or name it "_".

See more on https://sonarcloud.io/project/issues?id=wireapp_wire-ios&issues=AZ2_6krg8nV5pkv_ksOU&open=AZ2_6krg8nV5pkv_ksOU&pullRequest=4636
didFinishRecordingTo outputFileURL: URL,
from connections: [AVCaptureConnection],

Check warning on line 202 in wire-ios/Wire-iOS/Sources/UserInterface/Camera/MacVideoRecorderViewController.swift

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove the unused function parameter "connections" or name it "_".

See more on https://sonarcloud.io/project/issues?id=wireapp_wire-ios&issues=AZ2_6krg8nV5pkv_ksOV&open=AZ2_6krg8nV5pkv_ksOV&pullRequest=4636
error: Error?
) {
DispatchQueue.main.async {
self.recordButton.isSelected = false
self.recordingTimer?.invalidate()
self.recordingIndicator.isHidden = true

guard error == nil else { return }

self.dismiss(animated: true) {
self.onVideoRecorded?(outputFileURL)
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,17 @@ final class LinkInteractionTextView: UITextView {
weak var interactionDelegate: TextViewInteractionDelegate?

override var selectedTextRange: UITextRange? {
get { nil }
set { /* no-op */ }
get {
guard !ProcessInfo.processInfo.isiOSAppOnMac else { return super.selectedTextRange }
return nil
}
set {
guard !ProcessInfo.processInfo.isiOSAppOnMac else {
super.selectedTextRange = newValue
return
}
// no-op on iOS: prevents accidental text selection when tapping a message
}
}

// URLs with these schemes should be handled by the os.
Expand All @@ -51,6 +60,8 @@ final class LinkInteractionTextView: UITextView {
}

override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
// On macOS let the text view receive all hits so text can be selected with cursor.
guard !ProcessInfo.processInfo.isiOSAppOnMac else { return super.point(inside: point, with: event) }
let isInside = super.point(inside: point, with: event)
guard !UIMenuController.shared.isMenuVisible else { return false }
guard let position = characterRange(at: point), isInside else { return false }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,13 @@ final class ConversationTextMessageCell: UIView, ConversationMessageCell, TextVi

messageTextView.textColor = textForegroundColor
messageTextView.linkTextAttributes = linkTextAttributes

if ProcessInfo.processInfo.isiOSAppOnMac {
// On macOS the cursor and selection use tintColor, which inherits the app accent color.
// For own messages the bubble background IS the accent color, making the cursor invisible.
// Use the text foreground color instead so the cursor always contrasts with the bubble.
messageTextView.tintColor = textForegroundColor
}
}

func configure(with object: Configuration, animated: Bool) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,7 @@ final class ConversationMessageCellTableViewAdapter<
verticalFittingPriority: verticalFittingPriority
)
}

}

extension UITableView {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ final class PhotoPermissionsControllerStrategy: PhotoPermissionsController {

var isPhotoLibraryAuthorized: Bool {
switch PHPhotoLibrary.authorizationStatus() {
case .authorized: true
case .authorized, .limited: true
default: false
}
}
Expand Down
Loading
Loading