diff --git a/api/openapi.yaml b/api/openapi.yaml index 7550b9c..12cc24d 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -1156,6 +1156,22 @@ paths: "404": { $ref: "#/components/responses/Error" } "409": { $ref: "#/components/responses/Error" } + /api/raw: + get: + operationId: getRaw + description: > + A regular file anywhere on the server's disk, whole, with a content type chosen by its extension, as getTaskRaw + serves it. + parameters: + - { name: path, in: query, required: true, schema: { type: string }, description: "Slash-separated and absolute." } + responses: + "200": + description: The file's bytes. + content: + "*/*": { schema: { type: string, format: binary } } + "400": { $ref: "#/components/responses/Error" } + "404": { $ref: "#/components/responses/Error" } + /api/blobs: post: operationId: uploadBlob diff --git a/docs/macos.md b/docs/macos.md index 612e589..4fbba35 100644 --- a/docs/macos.md +++ b/docs/macos.md @@ -4,7 +4,7 @@ Platform constraints, build requirements and SwiftUI behavior observed live. ## Decision -**One web view only**: a task's Page view, which shows an HTML file from the task's worktree, one of its exposed previews, or a website an agent opened with `show_page`'s `url`, in a `WKWebView`, in the attached sheet over the lead pane. Nothing else renders HTML: no `WKWebView` or `WebView` in the window, a dialog, Markdown, diagrams or the terminal. Any other link to a web page opens in the default browser. Mermaid needs a browser DOM, and its automatic layout can't give the banded, nested look of a hand-drawn architecture picture, so byre has its own ```` ```diagram ```` block (rows, nested boxes, a side column, routed arrows, named highlights), laid out on a grid by the app; its syntax is the `diagrams` knowledge-base article. It is byre's only diagram format: a ```` ```mermaid ```` block shows as code, and the workflow stage graphs are drawn by generating its source. Agents see their diagrams through the `render_diagram` tool, which runs the installed app's own binary as `Byre render-diagram` (source on stdin, PNG on stdout), so the picture is exactly what the transcript draws; that mode opens no window and never touches the app's iCloud-synced store, so it uses the default text size. byre's Markdown dialect (choice markers, `
` sections, `#/task/` links, worktree image paths, `NO_REPLY`) matches no library, so the app parses it itself. +**One web view only**: the Page view, which shows an HTML file from a task's worktree, one of its exposed previews, or a website an agent opened with `show_page`'s `url`, in a `WKWebView`, in the attached sheet over the lead pane; and an HTML file from a project's repository in the project page's Page tab. Nothing else renders HTML: no `WKWebView` or `WebView` in the window, a dialog, Markdown, diagrams or the terminal. Any other link to a web page opens in the default browser. Mermaid needs a browser DOM, and its automatic layout can't give the banded, nested look of a hand-drawn architecture picture, so byre has its own ```` ```diagram ```` block (rows, nested boxes, a side column, routed arrows, named highlights), laid out on a grid by the app; its syntax is the `diagrams` knowledge-base article. It is byre's only diagram format: a ```` ```mermaid ```` block shows as code, and the workflow stage graphs are drawn by generating its source. Agents see their diagrams through the `render_diagram` tool, which runs the installed app's own binary as `Byre render-diagram` (source on stdin, PNG on stdout), so the picture is exactly what the transcript draws; that mode opens no window and never touches the app's iCloud-synced store, so it uses the default text size. byre's Markdown dialect (choice markers, `
` sections, `#/task/` links, worktree image paths, `NO_REPLY`) matches no library, so the app parses it itself. The app joins the tailnet as its own node with libtailscale and reaches byre's HTTPS origin on byre's node, so byre knows who is calling from the node itself. It never sends identity headers, and certificate validation is never relaxed. @@ -39,8 +39,8 @@ Every build is signed for distribution outside the App Store: the team's Develop - A plain `.background(color)` on a view inside a container with a shaped background (`.background(_, in: RoundedRectangle(...))`) fills a smaller rounded shape concentric with the container's, not its own rectangle, on macOS 27 and iOS 27. Stacked rows each drawn that way get notched ends, as the Changes view's diff lines did. Give a row's fill `in: Rectangle()` when it must reach its edges square. - A full-size diagram is drawn at its zoom, in tiles, and a pinch is folded into that zoom when it ends. Scaling a natural-size SwiftUI view down with `NSScrollView` magnification or `UIScrollView` zooming draws only about a window's width of it, so fitting a wide diagram that way shows its first few boxes and blank space. -- The Page view loads a worktree page from a plain-HTTP server the app runs on a loopback port (`http://127.0.0.1://`), which fetches each file through the server's `getTaskRaw` endpoint. The page's relative links to its stylesheets, scripts and images resolve to files beside it; absolute and `file://` references don't load. "↗ Browser" opens the same address. iOS reclaims the listening socket while the app is suspended, so the app binds it again on the same port each time it returns to the foreground. Previews load from their loopback forwards at `-.localhost`. App Transport Security blocks plain HTTP to `*.localhost` even with `NSAllowsLocalNetworking`; the `localhost` exception domain with subdomains in `Info.plist` allows it (loopback IP addresses need no exception). `NSAllowsArbitraryLoadsInWebContent` lets the Page view load plain-HTTP websites without lifting ATS for the app's own requests. -- Each Page view tab's `WKWebView` belongs to `Pages`, not to the SwiftUI view, so it keeps its page while the sheet is closed and agents can drive it with `drive_page` (navigate, JavaScript, capture) from the client connection. All tabs share WebKit's default persistent data store, so a site the human signs into stays signed in across launches. A link that opens a new window becomes a new tab of the same task. Capture refuses a tab that isn't on screen (sheet closed, or another tab showing), since its snapshot would show nothing the human sees. Passkey sign-in may fail, because WebKit allows WebAuthn in an embedded view only for apps with Apple's web browser entitlement; a password with a 2FA code works. +- The Page view loads a worktree page from a plain-HTTP server the app runs on a loopback port, which fetches each file through the server's `getTaskRaw`, `getProjectRaw` or `getRaw` endpoint. Each task's worktree and each project's repository is its own host, named by its ID (`http://.localhost:/`); every file outside them is served from one host for the whole disk (`disk.localhost`, through `getRaw`). macOS's resolver answers every `*.localhost` name with loopback itself (tools that query DNS directly, like `host` and `dig`, get NXDOMAIN), and WebKit sends the host lowercased, so the server finds the site by the lowercased name. Each host is its own origin: a page's relative references resolve to files beside it, and its `/…` references to the worktree or repository root. Files outside the worktree or repository are served at their absolute path, so `/…` there is the disk root. `file://` references don't load. "↗ Browser" opens the same address. iOS reclaims the listening socket while the app is suspended, so the app binds it again on the same port each time it returns to the foreground. Previews load from their loopback forwards at `-.localhost`. App Transport Security blocks plain HTTP to `*.localhost` even with `NSAllowsLocalNetworking`; the `localhost` exception domain with subdomains in `Info.plist` allows it (loopback IP addresses need no exception). `NSAllowsArbitraryLoadsInWebContent` lets the Page view load plain-HTTP websites without lifting ATS for the app's own requests. +- Each Page view tab's `WKWebView` belongs to `Pages`, not to the SwiftUI view, so it keeps its page while the sheet is closed and agents can drive it with `drive_page` (navigate, JavaScript, capture) from the client connection. All tabs share WebKit's default persistent data store, so a site the human signs into stays signed in across launches. A link that opens a new window becomes a new tab of the same task or project. Capture refuses a tab that isn't on screen (sheet closed, or another tab showing), since its snapshot would show nothing the human sees. Passkey sign-in may fail, because WebKit allows WebAuthn in an embedded view only for apps with Apple's web browser entitlement; a password with a 2FA code works. ## Updates diff --git a/internal/api/api.gen.go b/internal/api/api.gen.go index 5b6f378..01c1c1e 100644 --- a/internal/api/api.gen.go +++ b/internal/api/api.gen.go @@ -1904,6 +1904,12 @@ type GetProjectRawParams struct { Path string `form:"path" json:"path"` } +// GetRawParams defines parameters for GetRaw. +type GetRawParams struct { + // Path Slash-separated and absolute. + Path string `form:"path" json:"path"` +} + // GetTaskChangesParams defines parameters for GetTaskChanges. type GetTaskChangesParams struct { // Since A commit to compare with instead of the merge-base, such as a user.message event's head. Not allowed with snapshot. @@ -2222,6 +2228,9 @@ type ServerInterface interface { // (POST /api/projects/{projectId}/todo/tasks) StartTodoItem(w http.ResponseWriter, r *http.Request, projectId string) + // (GET /api/raw) + GetRaw(w http.ResponseWriter, r *http.Request, params GetRawParams) + // (PUT /api/reviews/{reviewId}/findings/{index}) SetFindingState(w http.ResponseWriter, r *http.Request, reviewId string, index int) @@ -3411,6 +3420,39 @@ func (siw *ServerInterfaceWrapper) StartTodoItem(w http.ResponseWriter, r *http. handler.ServeHTTP(w, r) } +// GetRaw operation middleware +func (siw *ServerInterfaceWrapper) GetRaw(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // Parameter object where we will unmarshal all parameters from the context + var params GetRawParams + + // ------------- Required query parameter "path" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, true, "path", r.URL.Query(), ¶ms.Path, runtime.BindQueryParameterOptions{Type: "string", Format: ""}) + if err != nil { + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "path"}) + } else { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "path", Err: err}) + } + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.GetRaw(w, r, params) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + // SetFindingState operation middleware func (siw *ServerInterfaceWrapper) SetFindingState(w http.ResponseWriter, r *http.Request) { @@ -4855,6 +4897,7 @@ func HandlerWithOptions(si ServerInterface, options StdHTTPServerOptions) http.H m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/api/sessions/{sessionId}/interrupt", wrapper.InterruptSession) m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/api/sessions/{sessionId}/pause", wrapper.PauseSession) m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/api/sessions/{sessionId}/resume", wrapper.ResumeSession) + m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/api/raw", wrapper.GetRaw) m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/api/blobs", wrapper.UploadBlob) m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/api/blobs/{blobId}", wrapper.GetBlob) m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/api/inbox", wrapper.ListInbox) @@ -5173,84 +5216,85 @@ var swaggerSpec = []string{ "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", - "/CqAKbT9crWKRiSwfGp8wAC5RNa/MbXsx4///wA=", + "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", + "2Y8f//8BAA==", } // decodeSpec returns the embedded OpenAPI spec as raw JSON bytes, diff --git a/internal/server/files.go b/internal/server/files.go index d79d42f..3abf042 100644 --- a/internal/server/files.go +++ b/internal/server/files.go @@ -240,6 +240,14 @@ func (s *Server) GetProjectRaw(w http.ResponseWriter, r *http.Request, projectID serveRaw(w, r, p.RepoPath, params.Path) } +func (s *Server) GetRaw(w http.ResponseWriter, r *http.Request, params api.GetRawParams) { + if !path.IsAbs(params.Path) { + writeError(w, http.StatusBadRequest, fmt.Errorf("%s is not an absolute path", params.Path)) + return + } + serveRaw(w, r, "/", params.Path) +} + func serveRaw(w http.ResponseWriter, r *http.Request, root, rel string) { if rel == "" { writeError(w, http.StatusBadRequest, errors.New("path is required")) diff --git a/mac/Sources/Byre/Dialogs.swift b/mac/Sources/Byre/Dialogs.swift index 6b62b7a..2974580 100644 --- a/mac/Sources/Byre/Dialogs.swift +++ b/mac/Sources/Byre/Dialogs.swift @@ -39,7 +39,7 @@ enum LeftTab: String { } enum ProjectTab { - case tasks, files, settings + case tasks, files, page, settings } @MainActor @@ -60,7 +60,6 @@ final class Overlays { @ObservationIgnored var endFilter: (token: String, run: () -> Void)? var listKeys = false var listCursor: String? - var projectTab = ProjectTab.tasks var taskList = false @ObservationIgnored var pickerFrame: CGRect = .zero @ObservationIgnored var menuHover = false diff --git a/mac/Sources/Byre/Files.swift b/mac/Sources/Byre/Files.swift index b2eff7b..fa2d23e 100644 --- a/mac/Sources/Byre/Files.swift +++ b/mac/Sources/Byre/Files.swift @@ -43,7 +43,7 @@ extension Store { func showPage(_ taskId: String, _ path: String) { route("#/task/\(taskId)") - openPage(.file(taskId: taskId, path: path)) + openPage(.file(.task(taskId), path: path)) } func showInFiles(_ taskId: String, _ path: String) { @@ -51,10 +51,14 @@ extension Store { route("#/task/\(taskId)/files") } - func showInProjectFiles(_ projectId: String, _ path: String, _ overlays: Overlays) { - filePlaces[.project(projectId)] = filePlace(at: path) - overlays.projectTab = .files + func openProjectFile(_ projectId: String, _ path: String) { route("#/project/\(projectId)") + if isPage(path) { + openPage(.file(.project(projectId), path: path)) + return + } + filePlaces[.project(projectId)] = filePlace(at: path) + projectTab = .files } private func filePlace(at path: String) -> FilePlace { @@ -392,8 +396,8 @@ private struct FilesBody: View { EntryRow(label: e._type == .dir ? "\(e.name)/" : e.name, kind: e._type, ignored: e.ignored, meta: meta, symlink: e.symlink, save: e._type == .file ? { model.save(join(l.path, e.name)) } : nil) { if e._type == .dir { model.go(FilePlace(dir: join(l.path, e.name))) - } else if e._type == .file && isPage(e.name), case .task(let taskId) = model.root { - store.openPage(.file(taskId: taskId, path: join(l.path, e.name))) + } else if e._type == .file && isPage(e.name), let root = model.root { + store.openPage(.file(root, path: join(l.path, e.name))) } else if e._type == .file { model.go(FilePlace(dir: l.path, file: join(l.path, e.name))) } diff --git a/mac/Sources/Byre/MarkdownView.swift b/mac/Sources/Byre/MarkdownView.swift index cacd3e6..f346f30 100644 --- a/mac/Sources/Byre/MarkdownView.swift +++ b/mac/Sources/Byre/MarkdownView.swift @@ -53,7 +53,7 @@ func openAppLink(_ url: URL, _ store: Store, _ overlays: Overlays) -> OpenURLAct store.route(q["hash"] ?? "") case "file": if let project = q["project"] { - store.showInProjectFiles(project, q["path"] ?? "", overlays) + store.openProjectFile(project, q["path"] ?? "") } else { store.openFile(q["task"] ?? "", q["path"] ?? "") } diff --git a/mac/Sources/Byre/Pages.swift b/mac/Sources/Byre/Pages.swift index fee8414..c5dfaae 100644 --- a/mac/Sources/Byre/Pages.swift +++ b/mac/Sources/Byre/Pages.swift @@ -5,13 +5,14 @@ import SwiftUI import WebKit enum Page: Hashable { - case file(taskId: String, path: String) + case file(FileRoot, path: String) case preview(taskId: String, port: Int) - case web(taskId: String, id: Int) + case web(FileRoot, id: Int) - var taskId: String { + var root: FileRoot { switch self { - case .file(let taskId, _), .preview(let taskId, _), .web(let taskId, _): taskId + case .file(let root, _), .web(let root, _): root + case .preview(let taskId, _): .task(taskId) } } @@ -34,13 +35,23 @@ enum Page: Hashable { @MainActor func url(_ store: Store) -> URL? { switch self { - case .file(let taskId, let path): store.pages.url(taskId, path) + case .file(let root, let path): store.pages.url(root, path) case .preview(let taskId, let port): store.forwards.url(taskId, port) case .web: store.pages.existing(self)?.url } } } +// Each site is its own loopback origin, so a page's "/…" references resolve against its worktree or repository root. +enum PageSite: Hashable { + case root(FileRoot) + case disk + + init(_ root: FileRoot, _ path: String) { + self = path.hasPrefix("/") ? .disk : .root(root) + } +} + private let pageExts: Set = ["html", "htm"] func isPage(_ path: String) -> Bool { @@ -54,43 +65,61 @@ struct ShownPages { extension Store { func openPage(_ page: Page) { - var shown = shownPages[page.taskId] ?? ShownPages(pages: [], current: page) + var shown = shownPages[page.root] ?? ShownPages(pages: [], current: page) if !shown.pages.contains(page) { shown.pages.append(page) } shown.current = page - shownPages[page.taskId] = shown - sheet = Sheet(owner: .task(page.taskId), view: .tab(.page)) + shownPages[page.root] = shown + switch page.root { + case .task(let taskId): sheet = Sheet(owner: .task(taskId), view: .tab(.page)) + case .project: projectTab = .page + } } func openWeb(_ taskId: String, _ url: URL) -> Int { route("#/task/\(taskId)") - let page = pages.web(taskId, url) + let page = pages.web(.task(taskId), url) openPage(page) return pages.tab(page) } func closePage(_ page: Page) { - guard var shown = shownPages[page.taskId], let i = shown.pages.firstIndex(of: page) else { + guard var shown = shownPages[page.root], let i = shown.pages.firstIndex(of: page) else { return } pages.drop(page) shown.pages.remove(at: i) if shown.pages.isEmpty { - shownPages[page.taskId] = nil - if sheet?.tab == .page { - sheet = nil + shownPages[page.root] = nil + pages.closeSites(page.root) + switch page.root { + case .task: + if sheet?.tab == .page { + sheet = nil + } + case .project: + if projectTab == .page { + projectTab = .files + } } return } if shown.current == page { shown.current = shown.pages[min(i, shown.pages.count - 1)] } - shownPages[page.taskId] = shown + shownPages[page.root] = shown + } + + func shownProjectTab() -> ProjectTab { + if projectTab == .page, focusedProject().flatMap({ shownPages[.project($0.id)] }) == nil { + return .tasks + } + return projectTab } func currentPage() -> Page? { - selectedTaskId.flatMap { shownPages[$0]?.current } + selectedTaskId.flatMap { shownPages[.task($0)]?.current } } func pageView(_ page: Page) -> WKWebView? { @@ -101,7 +130,7 @@ extension Store { } func pageTabs(_ taskId: String) -> String { - guard let shown = shownPages[taskId] else { + guard let shown = shownPages[.task(taskId)] else { return "This task's Page view has no tabs; open one with show_page." } return shown.pages.map { page in @@ -117,7 +146,7 @@ extension Store { } func drivenPage(_ taskId: String, _ tab: Int?) throws -> WKWebView { - guard let shown = shownPages[taskId] else { + guard let shown = shownPages[.task(taskId)] else { throw ApiError(status: 0, message: "this task's Page view has no tabs; open one with show_page") } var page = shown.current @@ -139,9 +168,11 @@ extension Store { final class Pages { private(set) var port: Int? private(set) var titles: [Page: String] = [:] - @ObservationIgnored var served: [String: [String: Data]] = [:] + @ObservationIgnored var served: [PageSite: [String: Data]] = [:] @ObservationIgnored var popup: ((Page) -> Void)? @ObservationIgnored private var listener: PageListener? + @ObservationIgnored private var hosts: [PageSite: String] = [:] + @ObservationIgnored private var sites: [String: PageSite] = [:] @ObservationIgnored private var views: [Page: WKWebView] = [:] @ObservationIgnored private var sources: [Page: URL] = [:] @ObservationIgnored private var titleWatches: [Page: NSKeyValueObservation] = [:] @@ -157,25 +188,45 @@ final class Pages { Task { @MainActor in self?.port = port } - }, served: { [weak self] taskId, path, body in + }, site: { @MainActor [weak self] host in + self?.sites[host] + }, served: { [weak self] site, path, body in Task { @MainActor in - self?.served[taskId, default: [:]][path] = body + self?.served[site, default: [:]][path] = body } }) } - func url(_ taskId: String, _ path: String) -> URL? { + func url(_ root: FileRoot, _ path: String) -> URL? { guard let port else { return nil } var c = URLComponents() c.scheme = "http" - c.host = "127.0.0.1" + c.host = host(PageSite(root, path)) c.port = port - c.path = "/\(taskId)/\(path)" + c.path = path.hasPrefix("/") ? path : "/\(path)" return c.url } + // WebKit lowercases the host it sends, so the listener finds the site by the lowercased name. + private func host(_ site: PageSite) -> String { + if let host = hosts[site] { + return host + } + let host = switch site { + case .root(.task(let id)), .root(.project(let id)): "\(id).localhost" + case .disk: "disk.localhost" + } + hosts[site] = host + sites[host.lowercased()] = site + return host + } + + func closeSites(_ root: FileRoot) { + served[.root(root)] = nil + } + func existing(_ page: Page) -> WKWebView? { views[page] } @@ -189,9 +240,9 @@ final class Pages { return view } - func web(_ taskId: String, _ url: URL) -> Page { + func web(_ root: FileRoot, _ url: URL) -> Page { lastTab += 1 - let page = Page.web(taskId: taskId, id: lastTab) + let page = Page.web(root, id: lastTab) make(page, WKWebViewConfiguration()).load(URLRequest(url: url)) return page } @@ -201,7 +252,7 @@ final class Pages { return nil } lastTab += 1 - let page = Page.web(taskId: from.taskId, id: lastTab) + let page = Page.web(from.root, id: lastTab) let view = make(page, config) popup?(page) return view @@ -297,9 +348,14 @@ final class Pages { port = nil } - static func fetch(_ api: API, _ taskId: String, _ path: String) async -> (status: Int, type: String, body: Data) { + static func fetch(_ api: API, _ site: PageSite, _ path: String) async -> (status: Int, type: String, body: Data) { + let endpoint = switch site { + case .root(.task(let id)): "/api/tasks/\(id)/raw" + case .root(.project(let id)): "/api/projects/\(id)/raw" + case .disk: "/api/raw" + } do { - let (body, response) = try await api.session.data(from: api.url("/api/tasks/\(taskId)/raw", [URLQueryItem(name: "path", value: path)])) + let (body, response) = try await api.session.data(from: api.url(endpoint, [URLQueryItem(name: "path", value: path)])) let http = response as! HTTPURLResponse return (http.statusCode, http.value(forHTTPHeaderField: "Content-Type") ?? "application/octet-stream", body) } catch { @@ -315,14 +371,16 @@ private final class PageListener: @unchecked Sendable { private let api: API private let queue = DispatchQueue(label: "run.fc.byre.pages") private let ready: @Sendable (Int) -> Void - private let served: @Sendable (String, String, Data) -> Void + private let site: @Sendable @MainActor (String) -> PageSite? + private let served: @Sendable (PageSite, String, Data) -> Void private var listener: NWListener? private var port: UInt16 = 0 private var closed = false - init(api: API, ready: @escaping @Sendable (Int) -> Void, served: @escaping @Sendable (String, String, Data) -> Void) { + init(api: API, ready: @escaping @Sendable (Int) -> Void, site: @escaping @Sendable @MainActor (String) -> PageSite?, served: @escaping @Sendable (PageSite, String, Data) -> Void) { self.api = api self.ready = ready + self.site = site self.served = served queue.async { self.listen() @@ -429,9 +487,8 @@ private final class PageListener: @unchecked Sendable { } return line[line.index(after: colon)...].trimmingCharacters(in: .whitespaces) } - // A browser must not reach this authenticated file bridge through a - // hostname that an attacker can rebind to loopback. - guard port != 0, hosts == ["127.0.0.1:\(port)"] else { + let suffix = ".localhost:\(port)" + guard port != 0, hosts.count == 1, let host = hosts.first?.lowercased(), host.hasSuffix(suffix) else { respond(conn, 403, "text/plain; charset=utf-8", Data("forbidden host".utf8)) return } @@ -440,17 +497,21 @@ private final class PageListener: @unchecked Sendable { respond(conn, 400, "text/plain; charset=utf-8", Data("bad request".utf8)) return } - let route = path.drop(while: { $0 == "/" }).split(separator: "/", maxSplits: 1, omittingEmptySubsequences: false) - guard route.count == 2, !route[1].isEmpty else { - respond(conn, 404, "text/plain; charset=utf-8", Data("not found".utf8)) - return - } - let taskId = String(route[0]) - let file = route[1].hasSuffix("/") ? "\(route[1])index.html" : String(route[1]) let api = api + let name = String(host.dropLast(":\(port)".count)) Task { - let (status, type, body) = await Pages.fetch(api, taskId, file) - self.served(taskId, file, body) + // Only *.localhost names the app handed out are served: the resolver answers those with loopback + // itself, so a site elsewhere can't rebind one onto this authenticated file bridge. + guard let site = await self.site(name) else { + self.respond(conn, 403, "text/plain; charset=utf-8", Data("forbidden host".utf8)) + return + } + var file = site == .disk ? path : String(path.drop(while: { $0 == "/" })) + if file.isEmpty || file.hasSuffix("/") { + file += "index.html" + } + let (status, type, body) = await Pages.fetch(api, site, file) + self.served(site, file, body) self.respond(conn, status, type, body) } } @@ -469,19 +530,21 @@ private final class PageWatch { private(set) var reloads = 0 @ObservationIgnored private var timer: Task? - func check(_ api: API, _ pages: Pages, _ taskId: String) { + func check(_ api: API, _ pages: Pages, _ root: FileRoot) { timer?.cancel() timer = Task { try? await Task.sleep(for: .milliseconds(1500)) var changed = false - for (path, shown) in pages.served[taskId] ?? [:] { - let fresh = await Pages.fetch(api, taskId, path).body - if Task.isCancelled { - return - } - if fresh != shown { - pages.served[taskId]?[path] = fresh - changed = true + for (site, files) in pages.served where site == .root(root) || site == .disk { + for (path, shown) in files { + let fresh = await Pages.fetch(api, site, path).body + if Task.isCancelled { + return + } + if fresh != shown { + pages.served[site]?[path] = fresh + changed = true + } } } if changed { @@ -525,14 +588,23 @@ struct PageTabs: View { } } } - .task(id: shown.current.taskId) { - let taskId = shown.current.taskId + .task(id: shown.current.root) { + let root = shown.current.root let watch = watch let api = store.api let pages = store.pages - let off = store.selectedFeed?.appended.add { events in - if events.contains(where: \.mayChangeWorktree) { - watch.check(api, pages, taskId) + let off: (() -> Void)? = switch root { + case .task: + store.selectedFeed?.appended.add { events in + if events.contains(where: \.mayChangeWorktree) { + watch.check(api, pages, root) + } + } + case .project(let id): + store.events.add { ev in + if ev.movesDefaultBranch && ev.projectId == id { + watch.check(api, pages, root) + } } } await untilCancelled() diff --git a/mac/Sources/Byre/Panes.swift b/mac/Sources/Byre/Panes.swift index 2b54f0e..1e62c85 100644 --- a/mac/Sources/Byre/Panes.swift +++ b/mac/Sources/Byre/Panes.swift @@ -224,7 +224,7 @@ struct TaskTabBody: View { case .files: FilesView(root: store.selectedTaskId.map(FileRoot.task)) case .page: - if let shown = store.selectedTaskId.flatMap({ store.shownPages[$0] }) { + if let shown = store.selectedTaskId.flatMap({ store.shownPages[.task($0)] }) { PageTabs(shown: shown) } case .captures: @@ -360,7 +360,7 @@ struct TabStrip: View { } switch t { case .board: return team - case .page: return store.shownPages[taskId] != nil + case .page: return store.shownPages[.task(taskId)] != nil case .captures: return !store.captures(taskId).isEmpty case .workflow: return false default: return true diff --git a/mac/Sources/Byre/Project.swift b/mac/Sources/Byre/Project.swift index c49113e..1a2d3fa 100644 --- a/mac/Sources/Byre/Project.swift +++ b/mac/Sources/Byre/Project.swift @@ -13,31 +13,53 @@ struct ProjectView: View { @ViewState private var filterFocus = 0 var body: some View { + let shown = store.focusedProject().flatMap { store.shownPages[.project($0.id)] } + let tab = store.shownProjectTab() VStack(spacing: 0) { if store.focusedProject() != nil { HStack(spacing: 4) { - PaneTabButton(label: "Tasks", active: overlays.projectTab == .tasks) { - overlays.projectTab = .tasks + PaneTabButton(label: "Tasks", active: tab == .tasks) { + store.projectTab = .tasks } - PaneTabButton(label: "Files", active: overlays.projectTab == .files) { - overlays.projectTab = .files + PaneTabButton(label: "Files", active: tab == .files) { + store.projectTab = .files } - PaneTabButton(label: "Settings", active: overlays.projectTab == .settings) { - overlays.projectTab = .settings + if shown != nil { + PaneTabButton(label: "Page", active: tab == .page) { + store.projectTab = .page + } + } + PaneTabButton(label: "Settings", active: tab == .settings) { + store.projectTab = .settings } Spacer(minLength: 0) + if tab == .page, let url = shown?.current.url(store) { + Button { + openExternal(url) + } label: { + Text("↗ Browser") + .font(Fonts.font(13)) + .foregroundStyle(Palette.accent) + .padding(.horizontal, 6) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .help("Open the page in the default browser") + } } .padding(.horizontal, 12) .overlay(alignment: .bottom) { Rectangle().fill(Palette.border).frame(height: 1) } } - if let p = store.focusedProject(), overlays.projectTab == .files { + if let p = store.focusedProject(), tab == .files { FilesView(root: .project(p.id)) + } else if let shown, tab == .page { + PageTabs(shown: shown) } else { ScrollView { if let p = store.focusedProject() { - ProjectBlock(project: p, todo: todos[p.id], settings: overlays.projectTab == .settings, filter: $filter, filterFocus: filterFocus) + ProjectBlock(project: p, todo: todos[p.id], settings: tab == .settings, filter: $filter, filterFocus: filterFocus) .padding(.horizontal, 16) .padding(.top, 8) .padding(.bottom, 16) @@ -78,7 +100,7 @@ struct ProjectView: View { } .onChange(of: overlays.filterTodo) { if store.focusedProject() != nil { - overlays.projectTab = .tasks + store.projectTab = .tasks filterFocus += 1 } } @@ -233,7 +255,7 @@ private func listedTasks(_ store: Store, _ overlays: Overlays) -> [ByreTask]? { if overlays.taskList { return store.projects.flatMap(active) } - if store.selectedTaskId == nil, overlays.projectTab == .tasks, let p = store.focusedProject() { + if store.selectedTaskId == nil, store.shownProjectTab() == .tasks, let p = store.focusedProject() { return active(p) } return nil diff --git a/mac/Sources/Byre/Store.swift b/mac/Sources/Byre/Store.swift index d52bb97..b4d1fe3 100644 --- a/mac/Sources/Byre/Store.swift +++ b/mac/Sources/Byre/Store.swift @@ -83,7 +83,8 @@ final class Store { var connected = false private(set) var focus = Focus() var sheet: Sheet? - var shownPages: [String: ShownPages] = [:] + var shownPages: [FileRoot: ShownPages] = [:] + var projectTab = ProjectTab.tasks var leadSessions: [AgentSession] = [] let leadFeed: Feed var jump: Jump? diff --git a/mac/Sources/Byre/Switcher.swift b/mac/Sources/Byre/Switcher.swift index 2ff3b6e..f0ca324 100644 --- a/mac/Sources/Byre/Switcher.swift +++ b/mac/Sources/Byre/Switcher.swift @@ -24,7 +24,7 @@ func runGo(_ key: String, _ store: Store, _ overlays: Overlays) { overlays.switcher?.close() store.sheet = nil if let project = store.focusedProject() ?? store.projects.first { - overlays.projectTab = .tasks + store.projectTab = .tasks overlays.takeListKeys(nil) store.focusProject(project.id) } @@ -287,7 +287,7 @@ final class SwitcherModel { go(task.id) case .project(let p): close() - overlays.projectTab = .tasks + store.projectTab = .tasks overlays.takeListKeys(nil) store.focusProject(p.id) case .newTask(let p):