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
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,9 @@ jobs:
- name: 网页逐请求语言(Accept-Language)
run: ./tools/smoke-accept-language.sh

- name: 远程访问只读策略
run: ./tools/smoke-remote-readonly.sh

- name: 传递文本(/ls/text + 转义 + 虚拟根文本行)
run: ./tools/smoke-text.sh

Expand Down
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

[简体中文](README_CN.md) | English

A small macOS app that moves files and text between your Mac and other devices (phone, tablet…) over WiFi — the other end just uses a browser, with no client to install.
A small macOS app that moves files and text between your Mac and other devices (phone, tablet…) over WiFi, with optional browser-only remote sharing — the other end just uses a browser, with no client to install.

- Pick a file or folder, scan the QR code that appears (or just open the link), and browse the files in your phone's browser (HTML, PDF, Markdown, images…)
- Two-way text transfer — type some text and share it via a QR code, or send text back from the phone to the Mac
Expand All @@ -22,6 +22,7 @@ A small macOS app that moves files and text between your Mac and other devices (
- After you share, the other end can send files and text back
- Shows who's currently viewing
- `localshare` command line: bring up the window to share, or `--headless` to print the link and QR code in the terminal
- Optional remote access through your own HTTPS origin and frp SSH gateway; remote shares are read-only and expire after one hour

## Why this app exists

Expand All @@ -40,6 +41,10 @@ The QR code points to something like `http://192.168.x.x:8080/?t=<random-token>`

> ⚠️ Traffic is plain HTTP (unencrypted). Best avoided on public networks like airports and cafés.

### Remote browser sharing

Configure your HTTPS origin, relay SSH host, port, and optional identity in Settings, then enable Remote Access on an active share. This requires the user-owned Nginx + frp SSH gateway described in [the remote-sharing plan](docs/REMOTE_SHARING_PLAN.md); the receiving device only needs a browser. The relay is inside the trust boundary, and remote access is read-only with a one-hour lifetime.

## Terminal usage

In Settings → Command-Line Tool, click "Install". After that you can share from the terminal in one command:
Expand Down
7 changes: 6 additions & 1 deletion README_CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

简体中文 | [English](README.md)

一个 macOS 小工具,在本机和手机等设备之间通过 WiFi 互通文件和文本,对端使用浏览器,无须安装客户端
一个 macOS 小工具,在本机和手机等设备之间通过 WiFi 互通文件和文本,也支持可选的纯浏览器跨网络分享,对端无须安装客户端

- 选中文件或文件夹,相机扫描出现的二维码,或直接打开链接,在手机浏览器里浏览这些文件(HTML、PDF、Markdown、图片……)
- 支持双向传送文本,编辑文字通过二维码分享后传输,并可从手机端发送文本回 Mac
Expand All @@ -22,6 +22,7 @@
- 支持分享后,由对端反向传输文件、文本
- 显示当前在线访客
- 命令行 `localshare` 命令:唤起窗口分享,或 `--headless` 在终端显示链接和二维码
- 可选通过自有 HTTPS 地址和 frp SSH 网关远程访问;远程分享只读,默认一小时后失效

## 为什么有这个 app

Expand All @@ -40,6 +41,10 @@

> ⚠️ 传输是明文 HTTP(没有加密)。机场咖啡厅等公共网络下最好不要使用。

### 远程浏览器分享

在「设置」里填写 HTTPS 公共地址、转发服务器 SSH 主机/端口和可选私钥路径,然后在正在分享的票据上打开「远程访问」。这需要按[远程分享计划](docs/REMOTE_SHARING_PLAN.md)部署自有 Nginx + frp SSH 网关,对端设备只需浏览器;转发服务器属于信任边界,远程访问只读,默认一小时后失效。

## 终端用法

在「设置 → 命令行工具」里点「安装」,之后可以在终端一键分享:
Expand Down
4 changes: 4 additions & 0 deletions Sources/LocalShare/App.swift
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
NSApp.activate(ignoringOtherApps: true)
}

func applicationWillTerminate(_ notification: Notification) {
AppState.shared?.stop()
}

// 关窗不退出:菜单栏图标常驻,服务继续广播;退出走菜单栏「退出」或 ⌘Q。
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
false
Expand Down
189 changes: 177 additions & 12 deletions Sources/LocalShare/AppState.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import AppKit
import SwiftUI
import Network

// 全局状态:当前分享对象(文件夹或单个文件)、服务运行态、网络接口候选、二维码 URL、
// 监听端口配置、最近分享历史、屏幕路由、权限(读取常开,上传可切换)。
Expand Down Expand Up @@ -45,6 +46,11 @@ final class AppState: ObservableObject {
@Published var textInboxEnabled = false // 「允许收文本」闸门(默认关,持久化;不限分享形态)
@Published var persistReceivedText = false // 「记住收到的文本」(默认关,持久化)
@Published var cliStatus: CLIInstaller.Status = .notInstalled // 命令行工具安装状态
@Published var remoteSettings = RemoteSettings()
@Published private(set) var remoteAccessEnabled = false
@Published private(set) var remoteStatus: RemoteTunnel.Status = .disabled
@Published private(set) var remoteExpiresAt: Date?
@Published private(set) var remoteError: String?

// GUI 进程仅构造一次,init 末尾自登记;AppDelegate 的 open 事件回调经它触达状态。
static private(set) var shared: AppState?
Expand All @@ -54,6 +60,10 @@ final class AppState: ObservableObject {
@Published private(set) var token = Token.generate()
private var server: FileServer?
private var viewerTimer: Timer?
private let remoteTunnel = RemoteTunnel()
private var remoteExpiryTimer: Timer?
private var networkMonitor: NWPathMonitor?
private var networkPathStatus: NWPath.Status?

private let portKey = "configuredPort"
private let bindSelectedOnlyKey = "bindSelectedOnly"
Expand All @@ -66,12 +76,28 @@ final class AppState: ObservableObject {
private let textInboxKey = "textInboxEnabled" // 收件箱闸门(默认关)
private let persistReceivedKey = "persistReceivedText" // 是否记住收到的文本(默认关)
private let receivedTextsKey = "receivedTexts" // 收件箱内容(仅 persistReceivedText 开时写入)
private let remoteOriginKey = "remotePublicOrigin"
private let remoteSSHHostKey = "remoteSSHHost"
private let remoteSSHPortKey = "remoteSSHPort"
private let remoteIdentityPathKey = "remoteSSHIdentityPath"
private static let remoteLifetime: TimeInterval = 60 * 60
// 配置端口优先,其余作回退(8080 列入回退以防配置端口占用)。
private let fallbackPorts: [in_port_t] = [8000, 8888, 9000, 8080]

init() {
let savedPort = UserDefaults.standard.integer(forKey: portKey)
if (1024...65535).contains(savedPort) { configuredPort = in_port_t(savedPort) }
let savedRemotePort = UserDefaults.standard.integer(forKey: remoteSSHPortKey)
remoteSettings = RemoteSettings(
publicOrigin: UserDefaults.standard.string(forKey: remoteOriginKey) ?? "",
sshHost: UserDefaults.standard.string(forKey: remoteSSHHostKey) ?? "",
sshPort: (1...65535).contains(savedRemotePort) ? savedRemotePort : 2200,
identityPath: UserDefaults.standard.string(forKey: remoteIdentityPathKey) ?? ""
)
remoteTunnel.onStateChange = { [weak self] status, error in
self?.remoteStatus = status
self?.remoteError = error
}
bindSelectedOnly = UserDefaults.standard.bool(forKey: bindSelectedOnlyKey) // 未写入默认 false
if let a = UserDefaults.standard.string(forKey: appearanceKey).flatMap(AppearancePref.init) { appearance = a }
if let l = UserDefaults.standard.string(forKey: langPrefKey).flatMap(LangPref.init) { langPref = l }
Expand Down Expand Up @@ -107,6 +133,7 @@ final class AppState: ObservableObject {
AppDelegate.pendingOpenURLs = []
setShared(urls)
}
startNetworkMonitoring()
}

// MARK: - 选择态派生
Expand All @@ -126,27 +153,34 @@ final class AppState: ObservableObject {
// MARK: - 派生 URL / 二维码

// 文件夹/多选模式 → 根地址;单文件模式 → 直链该文件(路径仅供浏览器显示文件名/扩展名)。
private func makeURL(host: String) -> String {
private func makeURL(base: String) -> String {
let q = "?t=\(token)"
let root = base.hasSuffix("/") ? String(base.dropLast()) : base
// 传递文本(收/发合一):二维码恒指 /ls/text——这一页既显示电脑共享的文本(可读可复制),
// 又在「允许手机发回来」开着时挂出发送框;只收文本时它退化成纯发送页。扫一次,双向都在这。
if isTextOnly || isReceiveOnly { return "http://\(host):\(port)/ls/text\(q)" }
if isTextOnly || isReceiveOnly { return "\(root)/ls/text\(q)" }
// 单文件直链该文件(文本与文件共存时走虚拟根,不直链,故附带 !hasText)。
if sharedIsFile, !hasText, let name = sharedItems.first?.lastPathComponent,
let enc = name.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) {
return "http://\(host):\(port)/\(enc)\(q)"
return "\(root)/\(enc)\(q)"
}
return "http://\(host):\(port)/\(q)"
return "\(root)/\(q)"
}

var remoteURL: String? {
guard remoteAccessEnabled, isRunning, let origin = remoteSettings.publicURL else { return nil }
return makeURL(base: origin.absoluteString)
}

var primaryURL: String? {
if let remoteURL { return remoteURL }
guard isRunning, port != 0, let ip = selectedInterface?.ip else { return nil }
return makeURL(host: ip)
return makeURL(base: "http://\(ip):\(port)")
}

var localURL: String? {
guard isRunning, port != 0, let host = localHost else { return nil }
return makeURL(host: host)
return makeURL(base: "http://\(host):\(port)")
}

var qrImage: NSImage? {
Expand All @@ -165,6 +199,129 @@ final class AppState: ObservableObject {
selectedInterface = interfaces.first
}

// Remote access is session-only. Connection details persist, but the public
// switch never does: reopening the app cannot silently publish a share.
var canEnableRemote: Bool { isRunning && !isEmpty && remoteSettings.isValid }

func saveRemoteSettings(_ settings: RemoteSettings) {
remoteSettings = settings
UserDefaults.standard.set(settings.publicOrigin, forKey: remoteOriginKey)
UserDefaults.standard.set(settings.sshHost, forKey: remoteSSHHostKey)
UserDefaults.standard.set(settings.sshPort, forKey: remoteSSHPortKey)
UserDefaults.standard.set(settings.identityPath, forKey: remoteIdentityPathKey)
guard remoteAccessEnabled else { return }
guard settings.isValid else {
disableRemoteAccess()
remoteError = L.remoteConfigInvalid(lang)
return
}
startRemoteTunnel()
}

func setRemoteAccessEnabled(_ on: Bool) {
guard on != remoteAccessEnabled else { return }
if on {
guard canEnableRemote else {
remoteError = L.remoteConfigHint(lang)
return
}
permission.add = false
textInboxEnabled = false
UserDefaults.standard.set(false, forKey: textInboxKey)
remoteAccessEnabled = true
remoteError = nil
remoteExpiresAt = Date().addingTimeInterval(Self.remoteLifetime)
scheduleRemoteExpiry()
applyRemotePolicy()
pushToServer()
startRemoteTunnel()
} else {
disableRemoteAccess()
}
}

func retryRemoteAccess() {
guard remoteAccessEnabled else { return }
remoteError = nil
startRemoteTunnel()
}

private func disableRemoteAccess(rotateToken: Bool = false) {
remoteTunnel.stop()
remoteExpiryTimer?.invalidate()
remoteExpiryTimer = nil
remoteAccessEnabled = false
remoteExpiresAt = nil
remoteError = nil
if rotateToken { token = Token.generate() }
if isRunning { pushToServer() }
}

private func expireRemoteAccess() {
guard remoteAccessEnabled, let expires = remoteExpiresAt, expires <= Date() else { return }
disableRemoteAccess(rotateToken: true)
remoteError = L.remoteExpired(lang)
}

private func scheduleRemoteExpiry() {
remoteExpiryTimer?.invalidate()
remoteExpiryTimer = Timer.scheduledTimer(withTimeInterval: Self.remoteLifetime, repeats: false) { [weak self] _ in
Task { @MainActor [weak self] in self?.expireRemoteAccess() }
}
}

private var tunnelLocalAddress: String {
bindSelectedOnly ? (selectedInterface?.ip ?? "127.0.0.1") : "127.0.0.1"
}

private var tunnelProxyAddresses: Set<String> {
var addresses: Set<String> = ["127.0.0.1", "::1"]
if let selected = selectedInterface?.ip { addresses.insert(selected) }
return addresses
}

private func applyRemotePolicy() {
server?.remoteAccessEnabled = remoteAccessEnabled
server?.trustedProxyAddresses = tunnelProxyAddresses
server?.uploadEnabled = permission.add && canToggleUpload && !remoteAccessEnabled
server?.textInboxEnabled = textInboxEnabled && !remoteAccessEnabled
}

private func startRemoteTunnel() {
guard remoteAccessEnabled, isRunning else { return }
guard let configuration = remoteSettings.tunnelConfiguration(localAddress: tunnelLocalAddress, localPort: port) else {
remoteStatus = .offline
remoteError = L.remoteConfigInvalid(lang)
return
}
applyRemotePolicy()
remoteTunnel.start(configuration)
}

private func startNetworkMonitoring() {
let monitor = NWPathMonitor()
networkMonitor = monitor
let queue = DispatchQueue(label: "localshare.network-monitor")
monitor.pathUpdateHandler = { [weak self] path in
let status = path.status
Task { @MainActor [weak self] in
guard let self else { return }
let statusChanged = self.networkPathStatus != status
self.networkPathStatus = status
guard self.isRunning else { return }
let oldInterfaces = self.interfaces
self.refreshNetwork()
guard self.remoteAccessEnabled else { return }
if self.bindSelectedOnly && oldInterfaces != self.interfaces {
self.rebindServer()
} else if statusChanged || oldInterfaces != self.interfaces {
self.startRemoteTunnel()
}
}
}
monitor.start(queue: queue)
}

// MARK: - 选择分享对象

func pickFolder() { pick(files: false, directories: true, message: L.pickFolderMsg(lang)) }
Expand Down Expand Up @@ -203,8 +360,8 @@ final class AppState: ObservableObject {
private func pushToServer() {
server?.token = token
server?.sharedText = hasText ? sharedText : nil
server?.textInboxEnabled = textInboxEnabled
server?.share = currentShare
applyRemotePolicy()
}

// 分享 / 更新一段文本(Mac→手机)。token 的「会话」维度与分享文件一致:只在会话边界轮换
Expand Down Expand Up @@ -273,11 +430,12 @@ final class AppState: ObservableObject {

// 仅「单个文件夹」分享有上传落点;单文件/多选时设置页开关置灰。
// 附带文本会把分享转成虚拟根(无单一落点),故此时上传也不可用。
var canToggleUpload: Bool { sharedItems.count == 1 && !sharedIsFile && !hasText }
var canToggleUpload: Bool { !remoteAccessEnabled && sharedItems.count == 1 && !sharedIsFile && !hasText }

func setUploadAllowed(_ on: Bool) {
guard !remoteAccessEnabled else { return }
permission.add = on && canToggleUpload
server?.uploadEnabled = permission.add
applyRemotePolicy()
}

private func resetUpload() {
Expand Down Expand Up @@ -307,11 +465,12 @@ final class AppState: ObservableObject {
// 「允许收文本」闸门。开:不限分享形态都能开,开了就把服务拉起(无分享时即「只收模式」),不轮换 token、
// 不重启(运行中只切 server 标志,发送表单随之显隐、已发链接继续有效)。关:若再无其它分享则停服务。
func setTextInboxEnabled(_ on: Bool) {
guard !(remoteAccessEnabled && on) else { return }
guard on != textInboxEnabled else { return }
textInboxEnabled = on
UserDefaults.standard.set(on, forKey: textInboxKey)
if isRunning {
if isServing { server?.textInboxEnabled = on } // 仍有理由服务:原地切标志
if isServing { applyRemotePolicy() } // 仍有理由服务:原地切标志
else { stop() } // 关掉且无其它分享 → 拆服务,回到空状态
} else if isServing {
start() // 从空状态开启 → 起服务(只收模式)
Expand Down Expand Up @@ -389,8 +548,10 @@ final class AppState: ObservableObject {
refreshNetwork()
let fs = FileServer(share: currentShare, token: token)
fs.sharedText = hasText ? sharedText : nil
fs.uploadEnabled = permission.add && canToggleUpload
fs.textInboxEnabled = textInboxEnabled
fs.remoteAccessEnabled = remoteAccessEnabled
fs.trustedProxyAddresses = tunnelProxyAddresses
fs.uploadEnabled = permission.add && canToggleUpload && !remoteAccessEnabled
fs.textInboxEnabled = textInboxEnabled && !remoteAccessEnabled
fs.onUpload = { url in // socket 线程 → 主线程
Task { @MainActor [weak self] in self?.recordReceived(url) }
}
Expand All @@ -407,6 +568,7 @@ final class AppState: ObservableObject {
isRunning = true
lastError = nil
startViewerPolling()
if remoteAccessEnabled { startRemoteTunnel() }
} catch {
// 绑定指定网卡失败(IP 刚因 DHCP/切网消失):退回全接口重试一次,给提示但不让分享中断。
if bindIP != nil {
Expand All @@ -417,6 +579,7 @@ final class AppState: ObservableObject {
isRunning = true
lastError = LStr.ifaceUnavailable(lang)
startViewerPolling()
if remoteAccessEnabled { startRemoteTunnel() }
return
}
}
Expand All @@ -430,6 +593,7 @@ final class AppState: ObservableObject {
private func rebindServer() {
guard isRunning else { return }
viewerTimer?.invalidate(); viewerTimer = nil
if remoteAccessEnabled { remoteTunnel.stop() }
server?.stop()
server = nil
start() // 复用 self.token,仅监听地址/端口随当前选择重算
Expand All @@ -450,6 +614,7 @@ final class AppState: ObservableObject {
}

func stop() {
disableRemoteAccess()
viewerTimer?.invalidate()
viewerTimer = nil
viewerCount = 0
Expand Down
Loading
Loading