Do Electron ao Swift
Oito anos de Electron, depois joguei tudo fora e comecei de novo em Swift. 890 MB viraram 14 MB. E no fim, a grade de fotos ainda precisou ir para o AppKit.
O Phiewer hoje: 14 MB, nativo. A grade roda em NSCollectionView, as miniaturas são SwiftUI.
O Phiewer está na Mac App Store desde 2017. Um visualizador de fotos que trabalha direto nas suas pastas, sem biblioteca e sem importação. Até o fim de 2025, era um app Electron.
Em julho de 2025 apaguei o projeto antigo e recomecei do zero no Xcode. Em janeiro de 2026 saiu a versão 6.0, escrita em Swift, 14 MB em vez de 890 MB. Alguns meses depois, precisei refazer justamente a peça mais importante, a grade de miniaturas. Mas vamos por partes.
Por que Electron, afinal?
Eu venho do desenvolvimento web. Já tinha feito apps e jogos para iOS com Objective-C e SpriteKit, mas um app de desktop inteiro em Objective-C era grande demais para mim. O Electron tornava isso possível: criar um app para Mac com HTML, CSS e JavaScript, com o que eu já sabia. E ainda havia o argumento que todo mundo conhece: depois dá para lançar também para Windows e Linux.
Nunca fiz isso. Nem teria sido possível, o código tinha ficado específico demais para o Mac com o tempo. Ou seja, aguentei toda essa dor de cabeça por uma vantagem que nunca usei.
O que me desgastou no Electron
Não foi um único grande problema. Foram muitos pequenos, que foram se acumulando com os anos:
- •O tamanho. 890 MB para um visualizador de fotos. Cada download e cada atualização trazia um navegador inteiro junto.
- •Nunca pareceu um app de Mac de verdade. Não importava quanto trabalho eu colocasse.
- •Módulos Node com código nativo. Eles precisam ser recompilados para cada versão do Electron. Um módulo funcionava com a nova versão do Electron, outro ainda não, ou nunca. No fim, eu não conseguia mais atualizar o Electron: a última versão do Phiewer ainda rodava no Electron 24, de abril de 2023. Quando recomecei no meio de 2025, o Electron já estava na versão 37. Além disso, havia pacotes que queriam, cada um, uma versão diferente do Node.
- •O Electron mudou debaixo dos meus pés. Minha janela acessava o processo principal diretamente pelo módulo
remote. Ele foi marcado como obsoleto no Electron 12 e removido no Electron 14, por segurança e desempenho. Compreensível, mas para mim significou reescrever todas as chamadas em dez arquivos para usar o pacote substituto@electron/remote. E a atualização para o Electron 12 eu desfiz no mesmo dia, porque navegar pelos vídeos ficou travando. - •Assinar para a App Store. Dois arquivos de entitlements, um para o app e outro para tudo o que está dentro dele: helpers, frameworks, bibliotecas. Mais um script que assina tudo na ordem certa. No fim, meu script chamava o
codesign33 vezes, uma para cada biblioteca e programa: ffmpeg, HandBrake, dcraw, sharp, libvips e assim por diante. Se eu adicionava um módulo novo e esquecia de colocá-lo no script, o upload voltava com uma biblioteca não assinada. Cheguei a remover um módulo porque ele só dava erro de assinatura no upload. - •Tudo em dobro. A versão gratuita lite era um segundo app separado. Cada build, cada assinatura, cada upload, duas vezes. E quando um recurso PRO novo, que a lite nem tinha, recebia uma correção, os números de versão e de build dos dois apps se desencontravam.
- •Arquivos RAW. Para isso eu usava o dcraw, uma ferramenta de linha de comando que já mal era mantida na época (a última versão é de 2018, desde então as câmeras novas são suportadas pela LibRaw). Para cada imagem RAW: criar uma pasta de cache, copiar o original para dentro, rodar o dcraw, renomear o resultado e carregar o TIFF na janela. Tudo com chamadas separadas de linha de comando, até
mkdir,cpemv. As imagens HEIC passavam pelo sips. As miniaturas eu gerava antes com o módulo sharp e guardava no meu próprio cache. Dá para imaginar quantos chamados chegaram porque algum formato RAW específico não aparecia direito.
Era assim no código, um pouco resumido:
var mkdir = spawn("mkdir", [rawFilesCacheFolder]);
mkdir.on("close", function () {
var copyOriginalFile = spawn("cp", [filepath, rawFilesCacheFolder]);
copyOriginalFile.on("close", function () {
// -T TIFF output, -w camera white balance, -h half size (50% faster)
var dcraw = spawn(dcrawpath, ["-T", "-w", "-h", cachedRawFile]);
dcraw.on("close", function (code) {
var mv = spawn("mv", [cachedTiff, renamedTiff]);
mv.on("close", function () {
// load the TIFF via XMLHttpRequest and draw it into a canvas ...
});
});
});
}); O comando de build do meu antigo package.json mostra melhor no que aquilo tinha se transformado: 43 dependências, do jQuery ao sharp e ao HandBrake. dcraw e ExifTool copiados à mão para node_modules, sharp recompilado para cada arquitetura, alguns módulos remendados com patch-package e, no fim, meu próprio script de assinatura:
"build": "rm -rf node_modules && rm -rf yarn.lock && yarn install
&& npm rebuild --platform=darwin --arch=arm64 sharp
&& mkdir node_modules/dcraw && cp ../phiewer-src/dcraw/* node_modules/dcraw/
&& mkdir node_modules/exiftool && cp ../phiewer-src/exiftool/* node_modules/exiftool/
&& gulp && npx patch-package
&& yarn package-mac
&& mv \"Phiewer PRO-darwin-arm64/Phiewer PRO.app\" ~/Desktop/
&& cp -f buildscripts/For_AppStore/sign.sh ~/Desktop/ && sh ~/Desktop/sign.sh" Mas o que mais me tirou do sério foi o sandbox. Durante o desenvolvimento o app roda sem sandbox, na App Store com. Coisas que funcionavam sem problema durante o desenvolvimento simplesmente falhavam em silêncio no app final, como ler uma pasta. Então era colocar mensagens de depuração no build final, compilar, assinar, testar e procurar no Stack Overflow o que significa EPERM e por que só aparece no app empacotado. Um exemplo: se você abre um único arquivo no sandbox, o app pode ler exatamente esse arquivo e nada mais da pasta. Mas um visualizador de fotos precisa da pasta inteira para as miniaturas e para passar de uma foto para outra. Então o Phiewer capturava o erro EPERM e mostrava uma segunda janela pedindo acesso à pasta. Mesma história com o sips, a ferramenta de imagens do macOS na linha de comando: eu queria usá-lo para o sistema gerar as miniaturas. No desenvolvimento funcionava, no app final não, e dessa vez sem nenhuma mensagem de erro.
E ainda tinha os security-scoped bookmarks. É com eles que um app da Mac App Store lembra a quais pastas pode acessar, mesmo depois de reiniciar. Para um visualizador de fotos que trabalha com pastas, nada funciona sem eles. Eu só conseguia testá-los na versão assinada da App Store, no desenvolvimento eles não existem. O Electron tinha uma função para isso, eu tinha integrado tudo e deveria funcionar. Não funcionava. Em algum momento abri um chamado e, sim, era um bug do Electron que ninguém tinha percebido ainda. Depois era esperar a correção. Corrigir por conta própria, sem IA naquela época, era complicado demais para mim. Eu odiava aquilo.
Depois vieram os chips da Apple
No começo o Phiewer só rodava em Macs Intel. Os primeiros Macs com chip da Apple saíram no fim de 2020. Tive um build ARM separado um ano depois, no fim de 2021, e meu primeiro binário universal só em janeiro de 2024. As ferramentas eram simplesmente ruins no início, e agora eu tinha os problemas de dependências de antes em dobro: sharp e ffmpeg, por exemplo, precisavam ser compilados e assinados separadamente para cada arquitetura.
O mais irritante: pelo desenvolvimento para iOS, eu sabia que podia ser diferente. Lá você clica em “Archive” no Xcode e pronto.
Apagar tudo e começar de novo
Em algum momento cansei de remendar. Em julho de 2025 apaguei todos os arquivos, criei um projeto novo no Xcode e comecei do zero, com uma folha em branco, o sonho de todo desenvolvedor. Dessa vez com Swift e SwiftUI. Não aproveitei nada do código antigo. Só o que eu sabia sobre o que o app precisa fazer e como ele deve ser.
Não era só pelo tamanho. Eu finalmente queria programar do jeito que a Apple planejou:
- •890 MB viraram 14 MB. Nada de navegador na bagagem, só o app.
- •Controles de verdade da Apple. A barra lateral é uma barra lateral de janela real, e não uma camada de JavaScript e CSS que abre e fecha. Tudo tem a aparência que a Apple prevê para cada versão do macOS.
- •Modo escuro sem cores próprias. Chega de definições de cor minhas, o sistema cuida disso.
- •RAW sem linha de comando. O macOS sabe ler RAW sozinho, e a LibRaw está integrada direto como biblioteca, em vez de chamada via
spawn. As miniaturas vêm do sistema, e não do meu próprio cache. - •Nunca mais módulos Node que não combinam com a versão do Electron. E nada de VS Code com HTML, CSS e JavaScript, e sim Xcode. Archive, upload, pronto.
- •Um app em vez de dois. PRO (compra única) e lite (grátis) sempre existiram. Hoje é o mesmo app com dois targets. Na lite tudo está lá e visível, mas os recursos PRO ficam desativados e com um selo PRO.
Até o lançamento levou cerca de meio ano. Em janeiro de 2026 saiu o Phiewer 6.0, grátis para todo mundo que já tinha comprado.
Na versão antiga eu não medi nada. Mas a sensação é que o app abre duas vezes mais rápido, e as miniaturas aparecem na hora. As reações foram fantásticas, e as vendas estão claramente melhores desde então. A nova versão lite, que mostra o que o PRO sabe fazer, e um preço um pouco mais alto com certeza também ajudaram.
E no fim, AppKit
Programar do jeito que a Apple planejou também não é sempre perfeito. O SwiftUI ainda tem seus limites com listas muito grandes. A Apple sabe disso: na WWDC 2025, anunciou que listas com mais de 100.000 itens agora carregam seis vezes mais rápido no Mac. E para algumas coisas, até apps feitos só em SwiftUI ainda recorrem ao AppKit hoje. Aí você usa o AppKit, e em termos de estabilidade o AppKit é um tanque.
No meu caso foi a grade de miniaturas. Ela era montada do jeito mais clássico: um LazyVGrid dentro de uma ScrollView, envolvido por um ScrollViewReader, para a grade rolar junto quando você percorre a pasta com as setas.
ScrollViewReader { proxy in
ScrollView {
LazyVGrid(columns: [GridItem(.adaptive(minimum: thumbnailSize))], spacing: 8) {
ForEach(fileBrowser.currentItems) { item in
FilesSectionItemView(item: item, size: thumbnailSize, ...)
.id(item.id)
}
}
.onChange(of: fileBrowser.selectedFile?.id) { selectedId in
if let selectedId {
withAnimation { proxy.scrollTo(selectedId, anchor: .center) }
}
}
}
} Rodava liso também com pastas grandes. Pelo menos era o que eu achava. Até que, numa pasta com 14.000 fotos, apertei a seta para a esquerda na primeira imagem e pulei para o fim da lista. O salto em si foi rápido. Mas depois disso, cada tecla demorava uns dois segundos até algo se mexer na tela. E o estranho: de volta ao começo da lista, tudo voltava ao normal.
Meu palpite (se você conhece melhor os detalhes internos, pode me corrigir): o LazyVGrid só monta as views que estão visíveis. Mas ele também não sabe onde fica um item que ainda não foi posicionado. Para rolar até o item 13.500, ele precisa calcular o layout de tudo o que vem antes. Ou seja, scrollTo fica mais caro quanto mais para baixo está o destino. Nada bom para um visualizador de fotos que passa o dia inteiro rolando.
O NSCollectionView com um flow layout sabe de antemão o tamanho de cada item. Onde fica o item n é uma conta simples. Por isso scrollToItems(at:scrollPosition:) custa sempre o mesmo, seja o destino o item 10 ou o item 100.000.
As células em si eu não queria reescrever, elas já faziam tudo: miniaturas, seleção, menu de contexto, renomear direto na miniatura, arrastar e soltar. Então troquei só o contêiner pelo AppKit. As células continuam em SwiftUI, dentro de uma NSHostingView em cada NSCollectionViewItem:
final class FilesGridHostingItem: NSCollectionViewItem {
private var hostingView: NSHostingView<AnyView>?
override func loadView() {
view = NSView()
view.wantsLayer = true
}
func host<Content: View>(_ rootView: Content) {
let wrapped = AnyView(rootView)
if let hostingView {
hostingView.rootView = wrapped // reuse: just swap the root view
} else {
let hv = NSHostingView(rootView: wrapped)
// ... pin to all four edges
hostingView = hv
}
}
}
// Scrolling to the selection is now a single call, no matter how far down it is
collectionView.scrollToItems(at: [IndexPath(item: index, section: 0)],
scrollPosition: .centeredVertically) Desde então, o mesmo salto na mesma pasta não tem mais atraso nenhum, e pastas com várias centenas de milhares de imagens também rolam suavemente. Como isso fica para os usuários está no artigo Impulso de desempenho no Phiewer.
O que ainda estava quebrado depois
A troca em si foi rápida. Deixar todo o resto certo de novo levou até setembro. Seis coisas que o SwiftUI resolvia para mim sem eu perceber:
- •Tags novas não apareciam. O
FileItemsó compara a URL noEquatable. Então, depois de mudar uma tag, o SwiftUI achava que a célula não tinha mudado. A solução: uma identidade própria feita de URL, tags e comentários (código abaixo). - •
reloadData()pula para o topo. A cada arquivo novo ou apagado. Se a nova lista não tem nenhum ID em comum com a antiga, foi uma troca de pasta e pular para o topo está certo. Se não, guardo antes o item visível mais alto e quanto dele está cortado, e restauro isso depois. - •O
withAnimationvaza para o AppKit. UmwithAnimationantigo em volta de um interruptor fazia o AppKit animar o novo layout e a correção da rolagem. A grade tremia. - •Miniaturas piscavam em cinza. Uma célula vinculada de novo começa com o
@Statevazio. Agora ela também olha direto no cache. - •Ao clicar, a grade escorregava debaixo do mouse, porque centralizava na seleção. Agora os cliques só rolam o necessário, as setas continuam centralizando. Pequena armadilha: para cima ou para baixo é preciso usar
.nearestHorizontalEdge. - •Layouts próprios foram mais fáceis do que eu esperava. O mosaico “Exposé” calcula todas as posições uma vez só em
prepare(). O mural nem precisa de layout próprio, porque a rotação e o deslocamento ficam na célula SwiftUI.
private struct FileCellIdentity: Hashable {
let url: URL
let tags: [String]
let comments: String
}
cell.host(FilesGridCellWrapper(item: fileItem, ...).id(FileCellIdentity(fileItem)))
O que eu faria diferente hoje
Desenvolver logo para a plataforma em que o app realmente roda. Briguei anos com o Electron para manter Windows e Linux em aberto, e nunca precisei de nenhum dos dois.
O SwiftUI não é lento, mas não serve para tudo. As células continuam em SwiftUI, e isso é bom. Mas em listas com dezenas de milhares de itens, em que você pula de um lugar para outro o tempo todo, o contêiner precisa saber de antemão onde está cada coisa. No Mac, isso se chama NSCollectionView.
Os usuários percebem a diferença. Ninguém nunca me escreveu “por favor, nada de app Electron”. Mesmo assim, perceberam.
Se alguém conseguiu deixar o LazyVGrid rápido com listas desse tamanho: estou curioso para saber como.
Sobre o Phiewer
O Phiewer é um visualizador de fotos e vídeos para macOS. A versão gratuita (lite) inclui o visualizador de imagens completo. O Phiewer PRO é uma compra única, sem assinatura.
