Compartir con otros usuarios: registros compartidos, permisos e interfaz del sistema
Compartir no es sincronizar con más dispositivos: es convertir un almacén de un solo dueño en un sistema multiusuario con control de acceso impuesto por el servidor. Esta lección desmonta la anatomía de una compartición —el objeto de compartición, la zona espejo del participante, los roles y permisos—, explica cómo se presenta y se acepta una invitación con la interfaz del sistema, y detalla las consecuencias que la autorización remota tiene sobre el diseño de tu interfaz y de tu modelo.
Mientras una app solo sincroniza los datos de su dueño entre dispositivos, el modelo de seguridad es trivial: todo lo que hay en la base privada pertenece a quien ha iniciado sesión y no existe ninguna operación que el servidor pueda negarle. La compartición rompe ese supuesto de raíz y con él se derrumban varias suposiciones que estaban repartidas por todo el código sin que nadie las hubiera escrito: que el identificador de un registro basta para localizarlo, que la base de datos correcta se conoce de antemano, que si el objeto está en pantalla se puede editar, y que borrar un dato significa que desaparece. A partir del momento en que un segundo usuario entra en escena, la autorización deja de ser una decisión de la interfaz y pasa a ser un veredicto del servidor que tu código puede consultar pero no dictar.
- Describir la anatomía de una compartición: raíz, objeto de compartición, zona propietaria y zona espejo del participante.
- Distinguir roles, permisos y estado de aceptación, y decidir entre invitación cerrada o enlace público.
- Presentar y aceptar invitaciones con la interfaz del sistema y gestionar la llegada de la invitación a la app.
- Adaptar interfaz y modelo a un mundo donde escribir puede estar prohibido y el propietario puede revocar el acceso.
La anatomía de una compartición
Compartir consiste en crear un registro especial, el objeto de compartición, que vive en la misma zona que aquello que se comparte y que actúa a la vez como lista de control de acceso y como generador de la invitación. Hay dos formas de crearlo y la elección condiciona todo lo demás. La primera toma un registro como raíz y comparte esa raíz junto con todo lo que la referencia jerárquicamente; la segunda comparte una zona entera, de modo que cualquier registro que se añada después queda incluido sin trámite. La jerárquica es más granular, la de zona es más simple de mantener.
Lo que ocurre en el lado del participante es la parte que más confusión genera. Sus datos no se copian: cuando acepta, aparece en su base compartida una zona que refleja la del propietario, con el mismo nombre pero con un identificador de dueño distinto. Esa zona no consume su cuota de iCloud, no está bajo su control y desaparecerá por completo si el propietario revoca el acceso o si él mismo abandona la compartición. Para el código del participante eso significa que la misma entidad puede vivir en dos bases de datos distintas y que localizar un registro exige saber en cuál buscar.
flowchart LR a[Propietario crea la raiz en su zona personalizada] --> b[Se crea el objeto de comparticion junto a la raiz] b --> c[El sistema genera una URL de invitacion] c --> d[El invitado abre la invitacion y acepta] d --> e[Aparece una zona espejo en la base compartida del invitado] e --> f[Lectura y escritura segun el permiso concedido] b --> g[El propietario revoca y la zona espejo desaparece]
// Compartir una jerarquia con raiz explicita
let comparticion = CKShare(rootRecord: proyecto)
comparticion[CKShare.SystemFieldKey.title] = "Proyecto de mudanza"
comparticion.publicPermission = .none // solo por invitacion nominal
let (guardados, _) = try await db.modifyRecords(
saving: [proyecto, comparticion], deleting: [], atomically: true
)
// Compartir una zona completa: todo lo que caiga dentro queda incluido
let deZona = CKShare(recordZoneID: zonaID)
deZona.publicPermission = .readOnly // cualquiera con el enlace puede leer
Hay una restricción que conviene conocer antes de diseñar: la raíz y todo su contenido deben estar en una zona personalizada de la base privada del propietario, no en la zona por defecto, y no se puede compartir algo que ya pertenece a otra compartición. Reorganizar datos existentes para poder compartirlos implica moverlos de zona, lo que en la práctica significa crearlos de nuevo con identificadores distintos. Si la app va a necesitar compartir algún día, la estructura de zonas debe preverlo desde el primer despliegue.
Roles, permisos y participantes
El objeto de compartición contiene una lista de participantes y cada uno lleva tres atributos que se confunden con facilidad. El rol dice qué relación tiene con la compartición: propietario, invitado nominal o usuario que llegó por un enlace abierto. El permiso dice qué puede hacer: solo leer o leer y escribir. Y el estado de aceptación dice en qué punto del proceso está: invitado pero sin responder, aceptado o retirado. Un participante invitado con permiso de escritura que aún no ha aceptado no puede escribir nada, y esa combinación es la fuente habitual de informes de fallo que no reproducen.
Invitación nominal
El propietario añade participantes por correo o teléfono y el permiso público queda en ninguno. Solo esas personas, identificadas por su cuenta, entran. Es el modo adecuado para datos sensibles.
Enlace abierto
El permiso público pasa a lectura o a escritura y cualquiera con la URL se convierte en participante. Cómodo y arriesgado: el enlace circula por canales que no controlas.
Permiso de escritura
Habilita crear, modificar y borrar dentro de la compartición. No convierte al participante en propietario: no puede invitar a otros ni cambiar los permisos salvo que el propietario lo permita.
Revocación
Quitar a un participante hace desaparecer la zona espejo de sus dispositivos en la siguiente sincronización, sin aviso propio. La interfaz debe tolerar que un dato deje de existir.
Una advertencia sobre identidad que ahorra sorpresas: el sistema identifica a los participantes por su cuenta de iCloud y solo revela su nombre y su dato de contacto si esa persona lo ha autorizado. Tu app no puede asumir que dispone del correo de nadie, y mostrar la lista de colaboradores puede requerir un permiso de acceso a los contactos para completar los nombres. Cuando el permiso falta, lo correcto es mostrar una identidad neutra y no un hueco vacío.
La interfaz del sistema
Apple ofrece un controlador de compartición que resuelve la parte más delicada del flujo: generar la invitación, elegir el canal de envío, presentar los participantes, cambiar permisos y permitir detener la compartición. Usarlo no es una comodidad opcional, es prácticamente obligatorio, porque replicar a mano la generación de la URL y la gestión de participantes produce un flujo peor y con más aristas legales sobre privacidad. En SwiftUI se integra envolviendo el controlador o, en versiones recientes, ofreciendo la compartición como contenido transferible en un enlace de compartir.
struct BotonCompartir: UIViewControllerRepresentable {
let comparticion: CKShare
let contenedor: CKContainer
func makeUIViewController(context: Context) -> UICloudSharingController {
let vc = UICloudSharingController(share: comparticion, container: contenedor)
vc.availablePermissions = [.allowPrivate, .allowReadWrite]
vc.delegate = context.coordinator
return vc
}
func updateUIViewController(_ vc: UICloudSharingController, context: Context) {}
func makeCoordinator() -> Coordinador { Coordinador() }
}
El otro extremo del flujo es la aceptación, y ahí hay un detalle que se olvida con frecuencia. Cuando alguien abre la invitación, el sistema no abre tu app por una URL normal: entrega los metadatos de la compartición a un método específico del delegado de escena, y ese método puede llamarse antes de que tu almacén esté listo. La implementación robusta guarda los metadatos, espera a que el contenedor exista y solo entonces acepta, porque aceptar dos veces o aceptar sin almacén produce estados a medias difíciles de diagnosticar.
func windowScene(_ escena: UIWindowScene,
userDidAcceptCloudKitShareWith metadatos: CKShare.Metadata) {
Task {
do {
try await esperarAlmacenListo()
let aceptar = CKAcceptSharesOperation(shareMetadatas: [metadatos])
aceptar.qualityOfService = .userInitiated
try await ejecutar(aceptar, en: CKContainer(identifier: metadatos.containerIdentifier))
await navegarA(metadatos.hierarchicalRootRecordID)
} catch {
await mostrarFalloDeInvitacion(error)
}
}
}
El espejo automático de SwiftData sincroniza la base privada, pero no ofrece hoy una API propia para crear ni aceptar comparticiones. Una app que necesite compartir debe usar NSPersistentCloudKitContainer con sus métodos de compartición, o gestionar los registros con CloudKit directamente. Comprobar este punto antes de elegir la capa de persistencia evita el peor de los rediseños: descubrir a mitad de proyecto que la funcionalidad estrella exige cambiar de fundamento.
Vivir con datos que no son tuyos
La primera consecuencia práctica es que la interfaz debe preguntar antes de ofrecer. Un participante con permiso de solo lectura verá botones de edición que al pulsarse producirán un error del servidor, y ese error llegará tarde, en forma de fallo de escritura, cuando el usuario ya ha invertido esfuerzo. Consultar el permiso antes de dibujar y deshabilitar lo que no procede convierte una frustración en una restricción comprensible. Los contenedores de Core Data ofrecen consultas directas sobre si un objeto puede modificarse o borrarse, y usarlas es más fiable que deducirlo del rol.
let editable = contenedor.canUpdateRecord(forManagedObjectWith: objeto.objectID)
let borrable = contenedor.canDeleteRecord(forManagedObjectWith: objeto.objectID)
La segunda consecuencia afecta al ciclo de vida de los datos. Un objeto compartido puede desaparecer sin que el usuario haya hecho nada, porque el propietario dejó de compartir, cambió los permisos o simplemente lo borró. Una interfaz que mantenga una referencia a un registro inexistente y falle al abrirlo es un error común y evitable: toda navegación hacia contenido compartido debe tolerar la ausencia y devolver al usuario a un lugar sensato con una explicación. Lo mismo vale para la sincronización, donde la desaparición de una zona entera es un suceso normal y no un fallo.
En la base privada basta con una suscripción de zona, porque la zona ya existe. En la compartida, la novedad más importante es la aparición de zonas nuevas cuando alguien te invita, y solo la suscripción de base de datos avisa de eso. Una app que se suscribe únicamente por zona descubrirá las comparticiones nuevas al reiniciar y su usuario concluirá, con razón, que la app no funciona.
El salto conceptual que separa la sincronización de la compartición no está en la API sino en dónde reside la verdad sobre lo que se puede hacer. Mientras hay un solo usuario, la interfaz es la autoridad: si el botón está visible, la operación se puede realizar, y el servidor nunca contradice esa suposición porque todo pertenece a quien pregunta. En cuanto entra un segundo usuario, la autoridad se traslada al servidor y tu interfaz pasa a ser, en el mejor de los casos, una predicción de lo que ese servidor permitirá. Esa inversión arrastra consecuencias que van mucho más allá de deshabilitar un botón. El identificador de un registro deja de ser suficiente para localizarlo, porque el mismo nombre existe en la zona del propietario y en la zona espejo de cada participante, y solo la combinación de zona y dueño lo identifica de verdad. La disponibilidad de un dato deja de ser monótona: algo que estaba puede dejar de estar sin intervención del usuario, de modo que cualquier estado de navegación que apunte a contenido compartido es una referencia débil. La privacidad deja de ser una propiedad del almacén y pasa a ser una propiedad de cada jerarquía, porque una raíz compartida arrastra consigo todo lo que la referencia, incluidos los campos que añadiste después sin pensar en quién los vería. Y la propia noción de borrar se desdobla: el propietario destruye, el participante solo se marcha. Diseñar bien la compartición consiste, entonces, en aceptar desde el principio que estás construyendo un sistema con control de acceso, y en tratar cada pantalla con la pregunta que se hace en cualquier sistema multiusuario serio: quién está mirando esto, qué le permite el servidor, y qué debe ver si mañana deja de permitírselo.
El objeto de compartición vive junto a la raíz, en una zona personalizada de la base privada del propietario, y puede cubrir una jerarquía o una zona entera. El participante no recibe una copia sino una zona espejo en su base compartida, que no consume su cuota y desaparece si le revocan el acceso. Rol, permiso y estado de aceptación son tres cosas distintas y las tres deben comprobarse. La interfaz del sistema resuelve invitación, permisos y revocación; la aceptación llega por un método propio de la escena y debe esperar a que el almacén exista. Y en la base compartida hace falta suscripción de base de datos, porque lo relevante es la aparición de zonas nuevas.
- Reorganiza tus datos para que la raíz compartible viva en una zona personalizada y documenta qué habrías tenido que migrar si no lo hubieras previsto.
- Implementa la creación de la compartición jerárquica y preséntala con el controlador del sistema, ofreciendo tanto invitación nominal como enlace de solo lectura.
- Acepta la invitación en una segunda cuenta y verifica que la zona espejo aparece en la base compartida, comprobando el identificador de dueño.
- Adapta la interfaz para consultar el permiso antes de dibujar controles de edición y comprueba el resultado con un participante de solo lectura.
- Revoca el acceso mientras el participante tiene el detalle abierto en pantalla y describe qué ve; corrige el comportamiento hasta que la degradación sea comprensible.