Skip to content
Merged
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: 8 additions & 0 deletions OpenDocumentReader.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@
E2A17B1000000000000000B0 /* test.ods in Resources */ = {isa = PBXBuildFile; fileRef = E2A17B1200000000000000B2 /* test.ods */; };
E2A17B1100000000000000B1 /* test.odp in Resources */ = {isa = PBXBuildFile; fileRef = E2A17B1300000000000000B3 /* test.odp */; };
E26C39392250DC6E009C484A /* WebKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E26C39382250DC6E009C484A /* WebKit.framework */; };
E2A17B2000000000000000C0 /* CoreFoundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E2A17B2200000000000000C2 /* CoreFoundation.framework */; };
E2A17B2100000000000000C1 /* CFNetwork.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E2A17B2300000000000000C3 /* CFNetwork.framework */; };
E2C008FA220F1CF80097C594 /* CoreWrapper.mm in Sources */ = {isa = PBXBuildFile; fileRef = E2C008F9220F1CF80097C594 /* CoreWrapper.mm */; };
E2D0B3D9226D945400534FCC /* StoreReviewHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2D0B3D8226D945400534FCC /* StoreReviewHelper.swift */; };
E2F7ED53220B54D600D63515 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2F7ED52220B54D600D63515 /* AppDelegate.swift */; };
Expand Down Expand Up @@ -103,6 +105,8 @@
E2A17B1200000000000000B2 /* test.ods */ = {isa = PBXFileReference; lastKnownFileType = file; path = test.ods; sourceTree = "<group>"; };
E2A17B1300000000000000B3 /* test.odp */ = {isa = PBXFileReference; lastKnownFileType = file; path = test.odp; sourceTree = "<group>"; };
E26C39382250DC6E009C484A /* WebKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WebKit.framework; path = System/Library/Frameworks/WebKit.framework; sourceTree = SDKROOT; };
E2A17B2200000000000000C2 /* CoreFoundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreFoundation.framework; path = System/Library/Frameworks/CoreFoundation.framework; sourceTree = SDKROOT; };
E2A17B2300000000000000C3 /* CFNetwork.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CFNetwork.framework; path = System/Library/Frameworks/CFNetwork.framework; sourceTree = SDKROOT; };
E2BB4B60220EF3A10056176B /* BridgingHeader.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = BridgingHeader.h; sourceTree = "<group>"; };
E2C008F9220F1CF80097C594 /* CoreWrapper.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = CoreWrapper.mm; sourceTree = "<group>"; };
E2C008FC220F1D570097C594 /* CoreWrapper.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = CoreWrapper.h; sourceTree = "<group>"; };
Expand Down Expand Up @@ -131,6 +135,8 @@
523A371528CCF28100876C77 /* StoreKit.framework in Frameworks */,
E23795302274844400BA7238 /* AdSupport.framework in Frameworks */,
E26C39392250DC6E009C484A /* WebKit.framework in Frameworks */,
E2A17B2000000000000000C0 /* CoreFoundation.framework in Frameworks */,
E2A17B2100000000000000C1 /* CFNetwork.framework in Frameworks */,
523A371328CCF27400876C77 /* AdServices.framework in Frameworks */,
E1A78CF72C1A53DB00CD43E4 /* Foundation.framework in Frameworks */,
D2DCD7104EBCC0F1A784E116 /* GoogleMobileAds in Frameworks */,
Expand Down Expand Up @@ -209,6 +215,8 @@
E2064DDF22CFA1BA006441F8 /* iAd.framework */,
E237952F2274844400BA7238 /* AdSupport.framework */,
E26C39382250DC6E009C484A /* WebKit.framework */,
E2A17B2200000000000000C2 /* CoreFoundation.framework */,
E2A17B2300000000000000C3 /* CFNetwork.framework */,
);
name = Frameworks;
sourceTree = "<group>";
Expand Down
10 changes: 9 additions & 1 deletion OpenDocumentReader/CoreWrapper.h
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,11 @@ typedef NS_ERROR_ENUM(CoreWrapperErrorDomain, CoreWrapperError) {
@interface CoreWrapper : NSObject

@property (nonatomic, copy, readonly) NSArray<NSString *> *pageNames;
@property (nonatomic, copy, readonly) NSArray<NSString *> *pagePaths;

/// One URL per page, in the order they should be shown. Loopback HTTP URLs
/// served by odrcore, or `file:` URLs below the output path when the app is
/// built to render offline - see `kCoreWrapperServesOverHttp`.
@property (nonatomic, copy, readonly) NSArray<NSURL *> *pageURLs;

- (BOOL)translate:(NSString *)inputPath
cache:(NSString *)cachePath
Expand All @@ -40,6 +44,10 @@ typedef NS_ERROR_ENUM(CoreWrapperErrorDomain, CoreWrapperError) {
into:(NSString *)outputPath
error:(NSError **)error;

/// Whether this URL is one odrcore is serving, rather than somewhere a link in
/// the document leads.
+ (BOOL)isServedURL:(NSURL *)url;

@end

NS_ASSUME_NONNULL_END
Expand Down
209 changes: 201 additions & 8 deletions OpenDocumentReader/CoreWrapper.mm
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,35 @@
#include <odr/document_element.hpp>
#include <odr/file.hpp>
#include <odr/html.hpp>
#include <odr/http_server.hpp>
#include <odr/odr.hpp>
#include <odr/exceptions.hpp>
#include <odr/global_params.hpp>

#include <netinet/in.h>
#include <sys/socket.h>
#include <unistd.h>

#include <algorithm>
#include <atomic>
#include <filesystem>
#include <optional>
#include <string>
#include <thread>

/// Whether the translated HTML reaches the web view over loopback HTTP instead
/// of being written to the output directory as files.
///
/// HTTP is what OpenDocument.droid does and where this is headed. odrcore then
/// renders a page when the web view asks for it, on one of the server's
/// threads, rather than rendering every page up front on whichever thread
/// called `translate`. Set this to `NO` to go back to files - the offline path
/// is kept working until the migration is finished, not as a runtime option.
static const BOOL kCoreWrapperServesOverHttp = YES;

/// Loopback port for that server, the same one OpenDocument.droid uses.
/// Nothing off the device can reach it: the socket is bound to 127.0.0.1.
static const std::uint32_t kCoreWrapperHttpPort = 29665;

NSErrorDomain const CoreWrapperErrorDomain = @"app.opendocument.CoreWrapperErrorDomain";

Expand Down Expand Up @@ -65,6 +87,145 @@ static bool CoreWrapperIsCombinedView(const odr::HtmlView &view) {
return selected;
}

static struct sockaddr_in CoreWrapperLoopbackAddress(std::uint32_t port) {
struct sockaddr_in address = {};
address.sin_len = sizeof(address);
address.sin_family = AF_INET;
address.sin_port = htons(static_cast<in_port_t>(port));
address.sin_addr.s_addr = htonl(INADDR_LOOPBACK);

return address;
}

/// Whether the port is free, asked by binding it and letting go again.
///
/// Without this, a port some other app is already listening on would answer the
/// readiness probe below and the app would spend the rest of its life handing
/// the web view addresses on a server that knows nothing about our documents.
///
/// `SO_REUSEADDR` matches what cpp-httplib sets, so a connection of ours still
/// in TIME_WAIT does not read as somebody else's port. `SO_REUSEPORT`, which
/// httplib also sets, is deliberately not: it would let this bind succeed
/// alongside a foreign listener, which is the case being ruled out.
static BOOL CoreWrapperPortIsFree(std::uint32_t port) {
int handle = socket(AF_INET, SOCK_STREAM, 0);
if (handle < 0) {
return NO;
}

int reuse = 1;
setsockopt(handle, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(reuse));

struct sockaddr_in address = CoreWrapperLoopbackAddress(port);
int bound = bind(handle, reinterpret_cast<struct sockaddr *>(&address), sizeof(address));
close(handle);

return bound == 0;
}

static BOOL CoreWrapperPortAnswers(std::uint32_t port) {
int handle = socket(AF_INET, SOCK_STREAM, 0);
if (handle < 0) {
return NO;
}

struct sockaddr_in address = CoreWrapperLoopbackAddress(port);
int connected = connect(handle, reinterpret_cast<struct sockaddr *>(&address), sizeof(address));
close(handle);

return connected == 0;
}

static std::optional<odr::HttpServer> g_server;

/// Set when `listen` comes back, which it only does when the bind failed or the
/// server was stopped. While it is clear, the socket on the port is ours.
static std::atomic<bool> g_serverStopped{false};

/// `listen` binds the socket on the server's own thread, so without waiting the
/// first request can lose the race and get "connection refused" instead of a
/// document.
static BOOL CoreWrapperWaitForServer(std::uint32_t port, NSTimeInterval timeout) {
NSDate *deadline = [NSDate dateWithTimeIntervalSinceNow:timeout];

while (true) {
if (g_serverStopped.load()) {
return NO;
}
// re-checked afterwards: a port that answers between our bind test and
// this point belongs to whoever won the race, and if that is not us
// then listen() has come back by now
if (CoreWrapperPortAnswers(port) && !g_serverStopped.load()) {
return YES;
}
if ([deadline timeIntervalSinceNow] <= 0) {
return NO;
}

usleep(10 * 1000);
}
}

/// Brings up the one server the app has, on first use, and leaves it running
/// for the rest of the process. Returns NO when the port could not be taken -
/// another app may hold it - in which case translate falls back to files.
///
/// Only attempted once: a port that is taken now is not going to be free on the
/// next document either, and retrying would fork the state of `g_server`.
static BOOL CoreWrapperStartServer() {
static BOOL running = NO;
static dispatch_once_t onceToken;

dispatch_once(&onceToken, ^{
if (!CoreWrapperPortIsFree(kCoreWrapperHttpPort)) {
return;
}

NSString *cachePath = [NSTemporaryDirectory() stringByAppendingPathComponent:@"odrcore-server"];

odr::HttpServer::Config config;
config.cache_path = std::string([cachePath UTF8String]);

try {
std::filesystem::create_directories(config.cache_path);
g_server = odr::HttpServer(config);
} catch (...) {
return;
}

// listen() only returns once the server is stopped, and the server is
// stopped when the process ends, so this thread is never joined
std::thread([] {
try {
g_server->listen("127.0.0.1", kCoreWrapperHttpPort);
} catch (...) {
}

g_serverStopped.store(true);
}).detach();

running = CoreWrapperWaitForServer(kCoreWrapperHttpPort, 5);
});

return running;
}

static NSString *const kCoreWrapperHttpHost = @"127.0.0.1";

/// Where the server serves a view: `HttpServer` routes `/file/<prefix>/<path>`
/// to the service connected under that prefix, and the pages ask for their
/// images and fonts relative to that, so those are covered by the same route.
static NSURL *CoreWrapperPageURL(const std::string &prefix, const std::string &path) {
NSString *escapedPath = [[NSString stringWithUTF8String:path.c_str()]
stringByAddingPercentEncodingWithAllowedCharacters:[NSCharacterSet URLPathAllowedCharacterSet]];

NSString *url = [NSString stringWithFormat:@"http://%@:%u/file/%s/%@", kCoreWrapperHttpHost,
static_cast<unsigned int>(kCoreWrapperHttpPort),
prefix.c_str(), escapedPath];

return [NSURL URLWithString:url];
}

/// odrcore's data files ship inside the app bundle. The path never changes at
/// runtime, so it is set once instead of on every translate call.
static void CoreWrapperEnsureDataPath() {
Expand All @@ -89,15 +250,20 @@ - (BOOL)translate:(NSString *)inputPath
error:(NSError **)error {
@synchronized(self) {
_pageNames = nil;
_pagePaths = nil;
_pageURLs = nil;

try {
CoreWrapperEnsureDataPath();

_html.reset();

BOOL overHttp = kCoreWrapperServesOverHttp && CoreWrapperStartServer();

odr::HtmlConfig config;
config.editable = editable;
// resource paths are resolved relative to an output directory, and
// in server mode there is none - odrcore rejects the combination
config.relative_resource_paths = !overHttp;

std::string inputPathCpp = std::string([inputPath UTF8String]);

Expand Down Expand Up @@ -168,17 +334,39 @@ - (BOOL)translate:(NSString *)inputPath
return NO;
}

_html = service.bring_offline(outputPathCpp, views);

NSMutableArray<NSString *> *pageNames = [[NSMutableArray alloc] init];
NSMutableArray<NSString *> *pagePaths = [[NSMutableArray alloc] init];
for (const auto &page : _html->pages()) {
[pageNames addObject:[NSString stringWithUTF8String:page.name.c_str()]];
[pagePaths addObject:[NSString stringWithUTF8String:page.path.c_str()]];
NSMutableArray<NSURL *> *pageURLs = [[NSMutableArray alloc] init];

if (overHttp) {
// a fresh prefix for every translation, because the web view
// caches by URL: re-translating after a password or an edit has
// to end up at an address it has not seen before
static std::atomic<std::uint64_t> translation{0};
std::string prefix = "odr" + std::to_string(++translation);

// drops the service of the document shown before this one, whose
// pages nobody is going to ask for again, along with its cache.
// the directory is recreated first because clear() walks it and
// the system is free to empty the temporary directory for us
std::filesystem::create_directories(g_server->config().cache_path);
g_server->clear();
g_server->connect_service(service, prefix);

for (const odr::HtmlView &view : views) {
[pageNames addObject:[NSString stringWithUTF8String:view.name().c_str()]];
[pageURLs addObject:CoreWrapperPageURL(prefix, view.path())];
}
} else {
_html = service.bring_offline(outputPathCpp, views);

for (const auto &page : _html->pages()) {
[pageNames addObject:[NSString stringWithUTF8String:page.name.c_str()]];
[pageURLs addObject:[NSURL fileURLWithPath:[NSString stringWithUTF8String:page.path.c_str()]]];
}
}

_pageNames = pageNames;
_pagePaths = pagePaths;
_pageURLs = pageURLs;

return YES;
} catch (odr::UnknownFileType &) {
Expand All @@ -202,6 +390,11 @@ - (BOOL)translate:(NSString *)inputPath
}
}

+ (BOOL)isServedURL:(NSURL *)url {
return [url.scheme isEqualToString:@"http"] && [url.host isEqualToString:kCoreWrapperHttpHost]
&& url.port.unsignedIntValue == kCoreWrapperHttpPort;
}

- (BOOL)backTranslate:(NSString *)diff into:(NSString *)outputPath error:(NSError **)error {
@synchronized(self) {
if (!_document.has_value()) {
Expand Down
31 changes: 21 additions & 10 deletions OpenDocumentReader/Document.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,15 @@ protocol DocumentDelegate: AnyObject {
enum DocumentError: Error {
case getHtml
case backTranslate
/// odrcore accepted the document but could not serve one of its pages.
case pageNotServed
}

class Document: UIDocument {

public var result: URL?
public var pageNames: [String]?
public var pagePaths: [String]?
public var pageURLs: [URL]?

public weak var delegate: DocumentDelegate?
public var loadProgress = Progress(totalUnitCount: 5)
Expand All @@ -28,7 +30,9 @@ class Document: UIDocument {

public var page: Int = 0 {
didSet {
parse()
// every page of the document already has an address, so turning to
// one is picking a URL rather than translating the file again
showPage()
}
}
public var password: String? {
Expand Down Expand Up @@ -61,6 +65,7 @@ class Document: UIDocument {
loadProgress.completedUnitCount = 2

result = nil
pageURLs = nil
delegate?.documentUpdateContent(self)

let cachePath = URL(fileURLWithPath: NSTemporaryDirectory())
Expand Down Expand Up @@ -92,15 +97,10 @@ class Document: UIDocument {
loadProgress.completedUnitCount = loadProgress.totalUnitCount

// translate only reports success once it has at least one page
let pagePaths = coreWrapper.pagePaths
let pageNames = coreWrapper.pageNames
self.pagePaths = pagePaths
self.pageNames = pageNames

// TODO: use all pages instead of just one!
result = URL(fileURLWithPath: pagePaths[min(page, pagePaths.count - 1)])
pageURLs = coreWrapper.pageURLs
pageNames = coreWrapper.pageNames

delegate?.documentUpdateContent(self)
showPage()

if !wasPageCountAnnounced {
delegate?.documentPagesChanged(self)
Expand All @@ -111,6 +111,17 @@ class Document: UIDocument {
delegate?.documentLoadingCompleted(self)
}

/// Shows the currently selected page, clamped to what the document has:
/// switching to page five of a spreadsheet and then editing it into four
/// sheets should not walk off the end.
private func showPage() {
guard let pageURLs, !pageURLs.isEmpty else { return }

result = pageURLs[min(max(page, 0), pageURLs.count - 1)]

delegate?.documentUpdateContent(self)
}

override func handleError(_ error: Error, userInteractionPermitted: Bool) {
CrashManager.shared.log(error)
}
Expand Down
Loading